diff --git a/README.md b/README.md index 3e4192d13..31956730e 100644 --- a/README.md +++ b/README.md @@ -64,21 +64,23 @@ For local development setup, see the [Development Guide](https://chriswritescode - **Skills** — Extend agent capabilities with shareable, scoped skill definitions - **Notifications** — Push notifications for session events, questions, errors, and completions - **Audio** — Text-to-speech and speech-to-text (browser native and OpenAI-compatible APIs) +- **Themes** — Light/dark/system appearance plus a color theme picker with the Manager default and 36 bundled OpenCode palettes - **Mobile & PWA** — Responsive mobile-first UI, installable on any device, iOS-optimized ## Architecture -OpenCode Manager is a pnpm workspace with three TypeScript packages: +OpenCode Manager is a pnpm workspace with four TypeScript packages: - `backend/` — Bun + Hono API server with Better Auth, SQLite migrations, OpenCode process management, SSE, schedules, and push notifications. - `frontend/` — React + Vite SPA using React Router, TanStack Query, Radix UI/Tailwind, service worker support, and mobile-first navigation. - `shared/` — shared Zod schemas, config helpers, types, and utilities consumed by both backend and frontend. +- `ocm-cli/` — `ocm` CLI that attaches your local OpenCode TUI to a repo hosted on the Manager. A MkDocs Material site (`docs/`) provides guides, feature docs, configuration, and troubleshooting. ## Development -This repo uses pnpm workspaces for `shared`, `backend`, and `frontend`. +This repo uses pnpm workspaces for `shared`, `backend`, `frontend`, and `ocm-cli`. ```bash pnpm install diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 69fc943cc..727cceb06 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -29,7 +29,7 @@ export function createInternalRoutes( app.route('/schedules', createScheduleRoutes(scheduleService)) app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) - app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient)) + app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient, { redactSecrets: true })) const repos = new Hono() repos.route('/', createInternalRepoRoutes(db, settingsService)) repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) diff --git a/backend/src/routes/opencode-config.ts b/backend/src/routes/opencode-config.ts index 760bbe067..027a633f5 100644 --- a/backend/src/routes/opencode-config.ts +++ b/backend/src/routes/opencode-config.ts @@ -1,21 +1,63 @@ -import { Hono } from 'hono' +import { Hono, type Context } from 'hono' import { z } from 'zod' -import { UpdateOpenCodeConfigRequestSchema } from '@opencode-manager/shared/schemas' +import { + UpdateOpenCodeConfigPatchRequestSchema, + UpdateOpenCodeConfigRequestSchema, +} from '@opencode-manager/shared/schemas' import { getWorkspacePath } from '@opencode-manager/shared/config/env' -import { ClientError, openCodeLocation } from '@opencode-manager/shared/opencode' +import { + ClientError, + mcpServerViewsFromConfig, + mcpStatusByName, + openCodeLocation, + type McpServerView, + type McpStatusMap, +} from '@opencode-manager/shared/opencode' import type { SettingsService } from '../services/settings' import type { OpenCodeClient } from '../services/opencode/client' import { OpenCodeConfigConflictError, + OpenCodeConfigRedactedValueError, OpenCodeConfigShadowedRemovalError, OpenCodeConfigSourceInvalidError, readOpenCodeConfigFile, withOpenCodeConfigLock, } from '../services/opencode-config-file' import { applyOpenCodeConfigUpdate, toOpenCodeConfigApplyResponse } from '../services/opencode-config-apply' +import { redactOpenCodeConfigContent, redactOpenCodeConfigFile } from '../services/opencode-config-redact' import { logger } from '../utils/logger' -export function createOpenCodeConfigRoutes(settingsService: SettingsService, openCodeClient: OpenCodeClient) { +interface OpenCodeConfigRoutesOptions { + redactSecrets?: boolean +} + +async function readMcpStatus(openCodeClient: OpenCodeClient): Promise { + try { + const servers = await openCodeClient.api.mcp.list(openCodeLocation(getWorkspacePath())) + return mcpStatusByName(servers.data) + } catch (error) { + logger.warn('Failed to read live MCP server status:', error) + return {} + } +} + +function mergeMcpServerStatus(views: McpServerView[], status: McpStatusMap) { + return views.map((view) => { + const live = status[view.name] + if (!live) return view + return { + ...view, + status: live.status, + ...('error' in live && live.error ? { error: live.error } : {}), + } + }) +} + +export function createOpenCodeConfigRoutes( + settingsService: SettingsService, + openCodeClient: OpenCodeClient, + options: OpenCodeConfigRoutesOptions = {}, +) { const app = new Hono() app.get('/', async (c) => { @@ -24,7 +66,7 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope if (!config) { return c.json({ error: 'No OpenCode config file found' }, 404) } - return c.json(config) + return c.json(options.redactSecrets ? redactOpenCodeConfigFile(config) : config) } catch (error) { logger.error('Failed to get OpenCode config:', error) return c.json({ error: 'Failed to get OpenCode config' }, 500) @@ -34,7 +76,18 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope app.get('/effective', async (c) => { try { const entries = await openCodeClient.api.config.get(openCodeLocation(getWorkspacePath())) - return c.json({ entries }) + return c.json({ + entries: options.redactSecrets + ? entries.map((entry) => + entry.type === 'document' + ? { + ...entry, + info: redactOpenCodeConfigContent(entry.info).content as typeof entry.info, + } + : entry, + ) + : entries, + }) } catch (error) { logger.error('Failed to get effective OpenCode config:', error) if (error instanceof ClientError) { @@ -47,6 +100,23 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope } }) + app.get('/mcp', async (c) => { + try { + const [config, status] = await Promise.all([ + withOpenCodeConfigLock(readOpenCodeConfigFile), + readMcpStatus(openCodeClient), + ]) + const views = mcpServerViewsFromConfig(config?.content.mcp) + return c.json({ + revision: config?.revision ?? null, + servers: mergeMcpServerStatus(views, status), + }) + } catch (error) { + logger.error('Failed to get OpenCode MCP servers:', error) + return c.json({ error: 'Failed to get OpenCode MCP servers' }, 500) + } + }) + app.put('/', async (c) => { let body: unknown try { @@ -60,40 +130,80 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400) } + return applyUpdate(c, { + content: parsed.data.content, + source: parsed.data.source, + expectedRevision: parsed.data.expectedRevision, + settingsService, + openCodeClient, + }, options.redactSecrets) + }) + + app.patch('/', async (c) => { + let body: unknown try { - const result = await applyOpenCodeConfigUpdate({ - content: parsed.data.content, - source: parsed.data.source, - expectedRevision: parsed.data.expectedRevision, - settingsService, - openCodeClient, - }) - 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) + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = UpdateOpenCodeConfigPatchRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400) } + + return applyUpdate(c, { + content: parsed.data.patch, + source: parsed.data.source, + expectedRevision: parsed.data.expectedRevision, + mode: 'merge', + settingsService, + openCodeClient, + }, options.redactSecrets) }) return app } + +async function applyUpdate( + c: Context, + input: Parameters[0], + redactSecrets = false, +) { + try { + const result = await applyOpenCodeConfigUpdate(input) + const response = toOpenCodeConfigApplyResponse(result) + if (!redactSecrets) { + return c.json(response.body, response.status) + } + return c.json({ + ...redactOpenCodeConfigFile(result.config), + ...(result.status === 'restart_pending' ? { restartRequired: true } : {}), + }, response.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 OpenCodeConfigRedactedValueError) { + return c.json({ error: error.message, paths: error.paths }, 400) + } + 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) + } +} diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 865341e6e..32ae6e766 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -721,19 +721,19 @@ The global configuration files on disk are the source of truth. Use the \`ocm\` ### GET /opencode-config -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. +Read the merged persisted global configuration and its source files. Returns \`404\` when no source exists. Secret values are replaced with \`\` 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. -**Response (\`OpenCodeConfigFile\`):** +**Response:** \`\`\`ts { path: string - content: object - rawContent: string - sources: Array<{ name: string, path: string, rawContent: string, content: object, isValid: boolean }> - revision: string + content: object // secrets replaced with "" isValid: boolean validationIssues?: Array<{ path: string, message: string }> updatedAt: number + sources: Array<{ name: string, path: string, content: object, isValid: boolean, validationIssues?: Array<{ path: string, message: string }>, updatedAt: number }> + revision: string + redactedPaths: string[] } \`\`\` @@ -748,28 +748,31 @@ Read the merged persisted global configuration and its source files. Returns \`4 } \`\`\` -### PUT /opencode-config +### PATCH /opencode-config -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. +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. -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. +Send \`expectedRevision\` from a read. A \`409\` means the source files changed: read again and reconcile rather than retrying stale content. **Request Body:** \`\`\`ts -{ content: object | string, expectedRevision: string, source?: "opencode.json" | "opencode.jsonc" } +{ patch: object, expectedRevision?: string, source?: "opencode.json" | "opencode.jsonc" } \`\`\` -**Example:** +**Example** — change one MCP server's bearer token without reading the file: \`\`\`json { "action": "request", "params": { - "method": "PUT", + "method": "PATCH", "path": "/opencode-config", "body": { - "expectedRevision": "revision-from-get", - "content": { - "theme": "dark" + "patch": { + "mcp": { + "servers": { + "linear": { "headers": { "Authorization": "Bearer " } } + } + } } } } @@ -777,11 +780,34 @@ For a raw edit, send a string with the exact source name from \`sources\`. Never \`\`\` **Response:** -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. +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. The response adds \`restartRequired: true\` only when the automatic reload failed, for example because the OpenCode server is unavailable. -Returns \`400\` for invalid configuration and \`409\` for a stale revision. +Returns \`400\` for invalid configuration or a \`\` value (\`paths\` lists them), and \`409\` for a stale revision. + +### GET /opencode-config/mcp + +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. + +\`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.\` entry or \`legacy\` for a flat \`mcp.\` 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. + +**Response:** +\`\`\`ts +{ + revision: string | null + servers: Array<{ + name: string + type: 'local' | 'remote' + command?: string[] + url?: string + enabled: boolean + shape: 'servers' | 'legacy' + status?: 'connected' | 'pending' | 'disabled' | 'failed' | 'needs_auth' + error?: string + }> +} +\`\`\` 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. @@ -790,7 +816,8 @@ Only changes to how the OpenCode process is launched need a user restart from Se ## 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 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 +- 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 +- GET /opencode-config redacts secrets; never reconstruct a secret you did not read, and never send a \`\` value back - The settings PATCH endpoint does NOT trigger an OpenCode reload or restart ` } diff --git a/backend/src/services/opencode-config-apply.ts b/backend/src/services/opencode-config-apply.ts index 91b238a98..0cc76be2f 100644 --- a/backend/src/services/opencode-config-apply.ts +++ b/backend/src/services/opencode-config-apply.ts @@ -10,6 +10,7 @@ import { restoreOpenCodeConfigSnapshot, serializeOpenCodeConfigSnapshot, updateOpenCodeConfigFile, + type OpenCodeConfigUpdateMode, withOpenCodeConfigLock, } from './opencode-config-file' import { opencodeServerManager } from './opencode-single-server' @@ -29,12 +30,11 @@ export interface ApplyOpenCodeConfigInput { content: Record | string source?: OpenCodeConfigSourceName expectedRevision?: string + mode?: OpenCodeConfigUpdateMode settingsService: SettingsService openCodeClient: OpenCodeClient } -const MCP_CONFIG_KEY = 'mcp' - export async function captureLastKnownGoodOpenCodeConfig(settingsService: SettingsService): Promise { const previous = await readOpenCodeConfigFile() if (previous?.isValid) { @@ -95,19 +95,19 @@ function requiresOpenCodeReload(previous: OpenCodeConfigFile | null, next: OpenC if (previous?.isValid !== next.isValid) { return true } - return listChangedTopLevelKeys(previous?.content ?? {}, next.content).some((key) => key !== MCP_CONFIG_KEY) + return listChangedTopLevelKeys(previous?.content ?? {}, next.content).length > 0 } export async function applyOpenCodeConfigUpdate( input: ApplyOpenCodeConfigInput, ): Promise { const { reloadRequired, config } = await withOpenCodeConfigLock(async () => { - const { content, source, expectedRevision, settingsService } = input + const { content, source, expectedRevision, mode, settingsService } = input const snapshot = await readOpenCodeConfigSnapshot() const previous = await readOpenCodeConfigFile(snapshot) - const next = await updateOpenCodeConfigFile(content, { source, expectedRevision, snapshot }) + const next = await updateOpenCodeConfigFile(content, { source, expectedRevision, snapshot, mode }) if (previous?.isValid) { const snapshot = serializeOpenCodeConfigSnapshot(previous) diff --git a/backend/src/services/opencode-config-file.ts b/backend/src/services/opencode-config-file.ts index 52fdc4c8b..acf6a95db 100644 --- a/backend/src/services/opencode-config-file.ts +++ b/backend/src/services/opencode-config-file.ts @@ -41,10 +41,13 @@ const OPENCODE_CONFIG_SNAPSHOT_MARKER = 'opencode-config-snapshot' const OPENCODE_CONFIG_SNAPSHOT_ARTIFACT_PREFIX = 'opencode-config-broken' +export type OpenCodeConfigUpdateMode = 'replace' | 'merge' + export interface UpdateOpenCodeConfigOptions { source?: OpenCodeConfigSourceName expectedRevision?: string snapshot?: OpenCodeConfigSnapshot + mode?: OpenCodeConfigUpdateMode } export class OpenCodeConfigConflictError extends Error { @@ -88,6 +91,18 @@ export class OpenCodeConfigShadowedRemovalError extends Error { } } +export const OPENCODE_CONFIG_REDACTED_VALUE = '' + +export class OpenCodeConfigRedactedValueError extends Error { + readonly paths: string[] + + constructor(paths: string[]) { + super('Configuration contains redacted placeholder values') + this.name = 'OpenCodeConfigRedactedValueError' + this.paths = paths + } +} + interface OpenCodeConfigParseResult { content: Record isValid: boolean @@ -177,9 +192,45 @@ function defineOwnConfigValue(target: Record, key: string, valu Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) } +function stripNullOpenCodeConfigValues(value: unknown): unknown { + if (!isPlainObject(value)) return value + const output: Record = {} + for (const key of Object.keys(value)) { + const entry = value[key] + if (entry === null) continue + defineOwnConfigValue(output, key, stripNullOpenCodeConfigValues(entry)) + } + return output +} + +function collectOpenCodeConfigRedactedPaths(value: unknown, basePath: string[] = []): string[] { + const paths: string[] = [] + if (typeof value === 'string') { + if (value === OPENCODE_CONFIG_REDACTED_VALUE) paths.push(basePath.join('.')) + return paths + } + if (Array.isArray(value)) { + value.forEach((entry, index) => { + paths.push(...collectOpenCodeConfigRedactedPaths(entry, [...basePath, String(index)])) + }) + return paths + } + if (isPlainObject(value)) { + for (const key of Object.keys(value)) { + paths.push(...collectOpenCodeConfigRedactedPaths(value[key], [...basePath, key])) + } + } + return paths +} + +interface MergeOpenCodeConfigValuesOptions { + deleteNullValues?: boolean +} + function mergeOpenCodeConfigValues( target: Record, source: Record, + options: MergeOpenCodeConfigValuesOptions = {}, ): Record { const output: Record = {} for (const key of Object.keys(target)) { @@ -187,14 +238,20 @@ function mergeOpenCodeConfigValues( } for (const key of Object.keys(source)) { const sourceValue = source[key] + if (options.deleteNullValues && sourceValue === null) { + delete output[key] + continue + } const targetValue = hasOwn(output, key) ? output[key] : undefined - defineOwnConfigValue( - output, - key, - isPlainObject(targetValue) && isPlainObject(sourceValue) - ? mergeOpenCodeConfigValues(targetValue, sourceValue) - : sourceValue, - ) + let nextValue: unknown + if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { + nextValue = mergeOpenCodeConfigValues(targetValue, sourceValue, options) + } else if (options.deleteNullValues) { + nextValue = stripNullOpenCodeConfigValues(sourceValue) + } else { + nextValue = sourceValue + } + defineOwnConfigValue(output, key, nextValue) } return output } @@ -348,27 +405,37 @@ export async function writeOpenCodeConfigFile( } function collectOpenCodeConfigPathOperations( - requested: Record, + next: Record, current: Record, + mode: OpenCodeConfigUpdateMode, basePath: JSONPath = [], ): OpenCodeConfigPathOperation[] { const operations: OpenCodeConfigPathOperation[] = [] - for (const key of Object.keys(requested)) { - const requestedValue = requested[key] + for (const key of Object.keys(next)) { + const nextValue = next[key] const hasCurrent = hasOwn(current, key) const currentValue = hasCurrent ? current[key] : undefined const nextPath = [...basePath, key] - if (isPlainObject(requestedValue) && isPlainObject(currentValue)) { - operations.push(...collectOpenCodeConfigPathOperations(requestedValue, currentValue, nextPath)) - } else if (!hasCurrent || !isDeepStrictEqual(requestedValue, currentValue)) { - operations.push({ path: nextPath, value: requestedValue }) + if (mode === 'merge' && nextValue === null) { + if (hasCurrent) operations.push({ path: nextPath, value: undefined }) + continue + } + if (isPlainObject(nextValue) && isPlainObject(currentValue)) { + operations.push(...collectOpenCodeConfigPathOperations(nextValue, currentValue, mode, nextPath)) + continue + } + const appliedValue = mode === 'merge' ? stripNullOpenCodeConfigValues(nextValue) : nextValue + if (!hasCurrent || !isDeepStrictEqual(appliedValue, currentValue)) { + operations.push({ path: nextPath, value: appliedValue }) } } - for (const key of Object.keys(current)) { - if (hasOwn(requested, key)) continue - operations.push({ path: [...basePath, key], value: undefined }) + if (mode === 'replace') { + for (const key of Object.keys(current)) { + if (hasOwn(next, key)) continue + operations.push({ path: [...basePath, key], value: undefined }) + } } return operations @@ -437,15 +504,24 @@ export async function updateOpenCodeConfigFile( return writeOpenCodeConfigFile(content, targetName, snapshot) } + const redactedPaths = collectOpenCodeConfigRedactedPaths(content) + if (redactedPaths.length > 0) { + throw new OpenCodeConfigRedactedValueError(redactedPaths) + } + const invalidSources = snapshot.sources.filter((source) => !source.isValid).map((source) => source.name) if (invalidSources.length > 0) { throw new OpenCodeConfigSourceInvalidError(invalidSources) } - OpenCodeConfigSchema.parse(content) + const mode = options.mode ?? 'replace' + const operations = collectOpenCodeConfigPathOperations(content, snapshot.content, mode) + + OpenCodeConfigSchema.parse( + mode === 'merge' ? mergeOpenCodeConfigValues(snapshot.content, content, { deleteNullValues: true }) : content, + ) const originalText = targetSource?.rawContent ?? '{}\n' - const operations = collectOpenCodeConfigPathOperations(content, snapshot.content) assertNoShadowedOpenCodeConfigRemovals(operations, targetSource, snapshot.sources, targetName) const updatedText = applyOpenCodeConfigPathOperations(originalText, operations) @@ -666,7 +742,7 @@ export async function foldLegacyConfigJsonSource(): Promise { return true } - const operations = collectOpenCodeConfigPathOperations(legacyContent, lowestPrecedenceSource.content) + const operations = collectOpenCodeConfigPathOperations(legacyContent, lowestPrecedenceSource.content, 'replace') .filter((operation) => operation.value !== undefined && !hasOpenCodeConfigPath(lowestPrecedenceSource.content, operation.path)) if (operations.length > 0) { const updatedText = applyOpenCodeConfigPathOperations(lowestPrecedenceSource.rawContent, operations) diff --git a/backend/src/services/opencode-config-redact.ts b/backend/src/services/opencode-config-redact.ts new file mode 100644 index 000000000..2b91bbdcc --- /dev/null +++ b/backend/src/services/opencode-config-redact.ts @@ -0,0 +1,152 @@ +import type { OpenCodeConfigFile } from '../types/settings' +import { isRecord } from './opencode/enforcement-config' +import { OPENCODE_CONFIG_REDACTED_VALUE } from './opencode-config-file' + +const SECRET_KEYS = new Set([ + 'apikey', + 'token', + 'accesstoken', + 'refreshtoken', + 'idtoken', + 'bearertoken', + 'authtoken', + 'clientsecret', + 'secret', + 'password', + 'passphrase', + 'authorization', + 'credential', + 'credentials', + 'privatekey', +]) + +const OPAQUE_VALUE_KEYS = new Set(['headers', 'environment']) + +export interface RedactedOpenCodeConfigContent { + content: Record + redactedPaths: string[] +} + +export interface RedactedOpenCodeConfigFile { + path: string + content: Record + isValid: boolean + validationIssues?: OpenCodeConfigFile['validationIssues'] + updatedAt: number + sources: Array<{ + name: OpenCodeConfigFile['sources'][number]['name'] + path: string + content: Record + isValid: boolean + validationIssues?: OpenCodeConfigFile['validationIssues'] + updatedAt: number + }> + revision: string + redactedPaths: string[] +} + +function normalizeSecretKey(key: string): string { + return key.toLowerCase().replace(/[-_]/g, '') +} + +function isSecretKey(key: string): boolean { + return SECRET_KEYS.has(normalizeSecretKey(key)) +} + +function keysAreNames(path: string[]): boolean { + const [head] = path + if (head === 'provider') { + return path.length === 1 || (path.length === 3 && path[2] === 'models') + } + if (head === 'agent') { + return path.length === 1 + } + if (head === 'mcp') { + return path.length === 1 || (path.length === 2 && path[1] === 'servers') + } + return false +} + +function isNameMapException(key: string): boolean { + return key === 'servers' || key === 'timeout' +} + +function redactOpaqueValue(value: unknown, path: string[], redactedPaths: string[]): unknown { + if (Array.isArray(value)) { + return value.map((item, index) => redactOpaqueValue(item, [...path, String(index)], redactedPaths)) + } + if (!isRecord(value)) { + if (typeof value !== 'string') return value + redactedPaths.push(path.join('.')) + return OPENCODE_CONFIG_REDACTED_VALUE + } + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, redactOpaqueValue(child, [...path, key], redactedPaths)]), + ) +} + +function redactValue(value: unknown, path: string[], redactedPaths: string[]): unknown { + if (Array.isArray(value)) { + return value.map((item, index) => redactValue(item, [...path, String(index)], redactedPaths)) + } + if (!isRecord(value)) return value + + const names = keysAreNames(path) + return Object.fromEntries( + Object.entries(value).map(([key, child]) => { + const childPath = [...path, key] + if (names && !isNameMapException(key)) { + return [key, redactValue(child, childPath, redactedPaths)] + } + if (OPAQUE_VALUE_KEYS.has(key)) { + return [key, redactOpaqueValue(child, childPath, redactedPaths)] + } + if (isSecretKey(key)) { + if (typeof child === 'string') { + redactedPaths.push(childPath.join('.')) + return [key, OPENCODE_CONFIG_REDACTED_VALUE] + } + if (isRecord(child) || Array.isArray(child)) { + return [key, redactOpaqueValue(child, childPath, redactedPaths)] + } + return [key, child] + } + return [key, redactValue(child, childPath, redactedPaths)] + }), + ) +} + +function uniqueSortedPaths(paths: string[]): string[] { + return [...new Set(paths)].toSorted() +} + +export function redactOpenCodeConfigContent( + content: Record, +): RedactedOpenCodeConfigContent { + const redactedPaths: string[] = [] + return { + content: redactValue(content, [], redactedPaths) as Record, + redactedPaths: uniqueSortedPaths(redactedPaths), + } +} + +export function redactOpenCodeConfigFile(config: OpenCodeConfigFile): RedactedOpenCodeConfigFile { + const redacted = redactOpenCodeConfigContent(config.content) + return { + path: config.path, + content: redacted.content, + isValid: config.isValid, + ...(config.validationIssues ? { validationIssues: config.validationIssues } : {}), + updatedAt: config.updatedAt, + sources: config.sources.map((source) => ({ + name: source.name, + path: source.path, + content: redactOpenCodeConfigContent(source.content).content, + isValid: source.isValid, + ...(source.validationIssues ? { validationIssues: source.validationIssues } : {}), + updatedAt: source.updatedAt, + })), + revision: config.revision, + redactedPaths: redacted.redactedPaths, + } +} diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index 04e21c6e1..c639d7d9e 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -10,7 +10,8 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [ 'PATCH /settings', 'GET /opencode-config', 'GET /opencode-config/effective', - 'PUT /opencode-config', + 'GET /opencode-config/mcp', + 'PATCH /opencode-config', 'POST /assistant/reload', 'GET /repos', 'GET /repos/*/git-info', diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts index 5358c5fed..dc50f4e34 100644 --- a/backend/test/routes/internal-opencode-config.test.ts +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -24,6 +24,9 @@ describe('internal/opencode-config routes', () => { let configGetMock: ReturnType let forwardRawMock: ReturnType let locationReloadMock: ReturnType + let mcpListMock: ReturnType + let mcpAddMock: ReturnType + let mcpRemoveMock: ReturnType function configPath(name: string): string { return path.join(ws.workspacePath, '.config/opencode', name) @@ -40,8 +43,15 @@ describe('internal/opencode-config routes', () => { configGetMock = vi.fn(() => Promise.resolve([])) forwardRawMock = vi.fn(() => Promise.resolve(new Response('{}'))) locationReloadMock = vi.fn(() => Promise.resolve()) + mcpListMock = vi.fn(() => Promise.resolve({ data: [] })) + mcpAddMock = vi.fn(() => Promise.resolve()) + mcpRemoveMock = vi.fn(() => Promise.resolve()) const openCodeClient = { - api: { config: { get: configGetMock }, location: { reload: locationReloadMock } }, + api: { + config: { get: configGetMock }, + location: { reload: locationReloadMock }, + mcp: { list: mcpListMock, add: mcpAddMock, remove: mcpRemoveMock }, + }, forwardRaw: forwardRawMock, } as unknown as OpenCodeClient const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager @@ -69,8 +79,23 @@ describe('internal/opencode-config routes', () => { expect(body.error).toBe('No OpenCode config file found') }) - it('GET /api/internal/opencode-config returns the merged persisted snapshot and sources', async () => { - await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.jsonc') + it('GET /api/internal/opencode-config returns the merged snapshot with secrets redacted and no raw source', async () => { + await writeOpenCodeConfigFile( + JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + providers: { example: { apiKey: 'secret-key' } }, + mcp: { + servers: { + linear: { + type: 'remote', + url: 'https://linear.example.com', + headers: { Authorization: 'Bearer secret' }, + }, + }, + }, + }), + 'opencode.jsonc', + ) const res = await app.request('/api/internal/opencode-config', { headers: authHeaders() }) @@ -78,19 +103,204 @@ describe('internal/opencode-config routes', () => { const body = await res.json() as { path: string content: Record - rawContent: string + rawContent?: string isValid: boolean updatedAt: number - sources: Array<{ name: string; path: string; rawContent: string }> + sources: Array<{ name: string; path: string; content: Record; rawContent?: string }> revision: string + redactedPaths: string[] } expect(body.path).toBe(configPath('opencode.jsonc')) - expect(body.rawContent).toBe(OPENCODE_CONFIG_SEED) - expect(body.content).toEqual({ $schema: 'https://opencode.ai/config.json' }) + expect(body.rawContent).toBeUndefined() + expect(body.sources[0] && 'rawContent' in body.sources[0]).toBe(false) + expect(body.content).toEqual({ + $schema: 'https://opencode.ai/config.json', + providers: { example: { apiKey: '' } }, + mcp: { + servers: { + linear: { + type: 'remote', + url: 'https://linear.example.com', + headers: { Authorization: '' }, + }, + }, + }, + }) expect(body.isValid).toBe(true) expect(body.updatedAt).toBeGreaterThan(0) expect(body.sources.map((source) => source.name)).toEqual(['opencode.jsonc']) expect(body.revision).toMatch(/^[a-f0-9]{64}$/) + expect(body.redactedPaths).toEqual([ + 'mcp.servers.linear.headers.Authorization', + 'providers.example.apiKey', + ]) + }) + + it('PATCH /api/internal/opencode-config merges only the named paths and reloads the server', async () => { + await writeOpenCodeConfigFile(JSON.stringify({ theme: 'dark', small_model: 's' }), 'opencode.jsonc') + + const res = await app.request('/api/internal/opencode-config', { + method: 'PATCH', + headers: { 'content-type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + patch: { mcp: { servers: { linear: { type: 'remote', url: 'https://linear.example.com' } } } }, + }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as { content: Record; rawContent?: string } + expect(body.content).toEqual({ + theme: 'dark', + small_model: 's', + mcp: { servers: { linear: { type: 'remote', url: 'https://linear.example.com' } } }, + }) + expect(body.rawContent).toBeUndefined() + expect('rawContent' in body).toBe(false) + expect(mcpAddMock).not.toHaveBeenCalled() + expect(locationReloadMock).toHaveBeenCalledTimes(1) + const onDisk = JSON.parse(await readFile(configPath('opencode.jsonc'), 'utf8')) as Record + expect(onDisk).toEqual(body.content) + }) + + it('PATCH /api/internal/opencode-config returns a redacted body without raw source', async () => { + await writeOpenCodeConfigFile( + JSON.stringify({ theme: 'dark', provider: { example: { apiKey: 'secret-key' } } }), + 'opencode.jsonc', + ) + + const res = await app.request('/api/internal/opencode-config', { + method: 'PATCH', + headers: { 'content-type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ patch: { provider: { example: { apiKey: 'updated-secret' } } } }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as { + content: Record + rawContent?: string + sources: Array<{ content: Record; rawContent?: string }> + redactedPaths: string[] + } + expect(body.rawContent).toBeUndefined() + expect('rawContent' in body).toBe(false) + expect(body.sources[0] && 'rawContent' in body.sources[0]).toBe(false) + expect(body.content).toEqual({ + theme: 'dark', + provider: { example: { apiKey: '' } }, + }) + expect(body.redactedPaths).toEqual(['provider.example.apiKey']) + }) + + it('PATCH /api/internal/opencode-config rejects a redacted placeholder without writing', async () => { + await writeOpenCodeConfigFile(JSON.stringify({ theme: 'dark' }), 'opencode.jsonc') + const before = await readFile(configPath('opencode.jsonc'), 'utf8') + + const res = await app.request('/api/internal/opencode-config', { + method: 'PATCH', + headers: { 'content-type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ patch: { provider: { example: { apiKey: '' } } } }), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string; paths: string[] } + expect(body.paths).toEqual(['provider.example.apiKey']) + expect(body.error).toContain('redacted placeholder') + await expect(readFile(configPath('opencode.jsonc'), 'utf8')).resolves.toBe(before) + }) + + it('PATCH /api/internal/opencode-config reloads for a non-mcp change', async () => { + await writeOpenCodeConfigFile(JSON.stringify({ theme: 'dark' }), 'opencode.jsonc') + + const res = await app.request('/api/internal/opencode-config', { + method: 'PATCH', + headers: { 'content-type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ patch: { theme: 'light' } }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as { content: Record } + expect(body.content).toEqual({ theme: 'light' }) + expect(locationReloadMock).toHaveBeenCalledTimes(1) + }) + + it('PATCH /api/internal/opencode-config returns 409 for a stale expectedRevision', async () => { + const initial = await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.jsonc') + await writeFile(configPath('opencode.json'), '{"model":"a/b"}', 'utf8') + + const res = await app.request('/api/internal/opencode-config', { + method: 'PATCH', + headers: { 'content-type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ patch: { theme: 'light' }, expectedRevision: initial.revision }), + }) + + expect(res.status).toBe(409) + const body = await res.json() as { expectedRevision: string; actualRevision: string } + expect(body.expectedRevision).toBe(initial.revision!) + expect(body.actualRevision).not.toBe(initial.revision!) + }) + + it('GET /api/internal/opencode-config/mcp lists configured servers with redacted config and live status', async () => { + await writeOpenCodeConfigFile( + JSON.stringify({ + mcp: { + servers: { + linear: { + type: 'remote', + url: 'https://linear.example.com', + headers: { Authorization: 'Bearer secret' }, + }, + local: { type: 'local', command: ['npx', 'local'], disabled: true }, + }, + legacy: { type: 'local', command: ['npx', 'legacy'], enabled: false }, + }, + }), + 'opencode.jsonc', + ) + mcpListMock.mockResolvedValue({ + data: [ + { name: 'linear', status: { status: 'connected' } }, + { name: 'local', status: { status: 'disabled' } }, + ], + }) + + const res = await app.request('/api/internal/opencode-config/mcp', { headers: authHeaders() }) + + expect(res.status).toBe(200) + const body = await res.json() as { + revision: string | null + servers: Array<{ + name: string + type: string + command?: string[] + url?: string + enabled: boolean + shape: string + status?: string + }> + } + expect(body.revision).toMatch(/^[a-f0-9]{64}$/) + expect(body.servers).toEqual([ + { name: 'legacy', type: 'local', command: ['npx', 'legacy'], enabled: false, shape: 'legacy' }, + { name: 'linear', type: 'remote', url: 'https://linear.example.com', enabled: true, shape: 'servers', status: 'connected' }, + { name: 'local', type: 'local', command: ['npx', 'local'], enabled: false, shape: 'servers', status: 'disabled' }, + ]) + expect(mcpListMock).toHaveBeenCalledWith({ location: { directory: ws.workspacePath } }) + }) + + it('GET /api/internal/opencode-config/mcp returns config-only when the server is unreachable', async () => { + await writeOpenCodeConfigFile( + JSON.stringify({ mcp: { servers: { local: { type: 'local', command: ['npx', 'local'] } } } }), + 'opencode.jsonc', + ) + mcpListMock.mockRejectedValue(new Error('unreachable')) + + const res = await app.request('/api/internal/opencode-config/mcp', { headers: authHeaders() }) + + expect(res.status).toBe(200) + const body = await res.json() as { servers: Array> } + expect(body.servers).toEqual([ + { name: 'local', type: 'local', command: ['npx', 'local'], enabled: true, shape: 'servers' }, + ]) }) it('PUT /api/internal/opencode-config writes the file and applies it through a location reload without a restart', async () => { @@ -146,6 +356,34 @@ describe('internal/opencode-config routes', () => { await expect(readFile(configPath('opencode.jsonc'), 'utf8')).resolves.toBe(OPENCODE_CONFIG_SEED) }) + it('PUT /api/internal/opencode-config returns a redacted body without raw source', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.jsonc') + + const res = await app.request('/api/internal/opencode-config', { + method: 'PUT', + headers: { 'content-type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + content: { $schema: 'https://opencode.ai/config.json', provider: { example: { apiKey: 'secret-key' } } }, + }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as { + content: Record + rawContent?: string + sources: Array<{ content: Record; rawContent?: string }> + redactedPaths: string[] + } + expect(body.rawContent).toBeUndefined() + expect('rawContent' in body).toBe(false) + expect(body.sources[0] && 'rawContent' in body.sources[0]).toBe(false) + expect(body.content).toEqual({ + $schema: 'https://opencode.ai/config.json', + provider: { example: { apiKey: '' } }, + }) + expect(body.redactedPaths).toEqual(['provider.example.apiKey']) + }) + it('PUT /api/internal/opencode-config returns 409 for a stale expectedRevision', async () => { const initial = await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.jsonc') await writeFile(configPath('opencode.json'), '{"model":"a/b"}', 'utf8') @@ -207,10 +445,14 @@ describe('internal/opencode-config routes', () => { expect(body.error).toBe('Invalid JSON') }) - it('GET /api/internal/opencode-config/effective returns the running config entries without writing them back', async () => { + it('GET /api/internal/opencode-config/effective redacts document config entries without writing them back', async () => { await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.jsonc') const entries = [ - { type: 'document', path: configPath('opencode.jsonc'), info: { theme: 'dark' } }, + { + type: 'document', + path: configPath('opencode.jsonc'), + info: { theme: 'dark', provider: { example: { apiKey: 'secret-key' } } }, + }, { type: 'directory', path: path.join(ws.workspacePath, '.config', 'opencode') }, ] configGetMock.mockImplementation(() => Promise.resolve(entries)) @@ -218,7 +460,16 @@ describe('internal/opencode-config routes', () => { const res = await app.request('/api/internal/opencode-config/effective', { headers: authHeaders() }) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ entries }) + expect(await res.json()).toEqual({ + entries: [ + { + type: 'document', + path: configPath('opencode.jsonc'), + info: { theme: 'dark', provider: { example: { apiKey: '' } } }, + }, + { type: 'directory', path: path.join(ws.workspacePath, '.config', 'opencode') }, + ], + }) expect(configGetMock).toHaveBeenCalledWith({ location: { directory: ws.workspacePath } }) const persisted = await readOpenCodeConfigFile() expect(persisted?.content).toEqual({ $schema: 'https://opencode.ai/config.json' }) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index d9388057c..c2ca9d75b 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -92,7 +92,8 @@ describe('buildSettingsSkill', () => { const skill = buildSettingsSkill() expect(skill).toContain('## OpenCode Configuration') expect(skill).toContain('GET /opencode-config') - expect(skill).toContain('PUT /opencode-config') + expect(skill).toContain('PATCH /opencode-config') + expect(skill).toContain('GET /opencode-config/mcp') expect(skill).toContain('restartRequired') expect(skill).toContain('Never attempt the restart yourself') }) diff --git a/backend/test/services/opencode-config-apply.test.ts b/backend/test/services/opencode-config-apply.test.ts index 82a55d291..624ff7f83 100644 --- a/backend/test/services/opencode-config-apply.test.ts +++ b/backend/test/services/opencode-config-apply.test.ts @@ -65,7 +65,14 @@ function parseSnapshot(snapshot: string): { version: number; sources: Array<{ na } const locationReloadMock = vi.hoisted(() => vi.fn<() => Promise>()) -const openCodeClient = { api: { location: { reload: locationReloadMock } } } as unknown as OpenCodeClient +const mcpAddMock = vi.hoisted(() => vi.fn<() => Promise>()) +const mcpRemoveMock = vi.hoisted(() => vi.fn<() => Promise>()) +const openCodeClient = { + api: { + location: { reload: locationReloadMock }, + mcp: { add: mcpAddMock, remove: mcpRemoveMock }, + }, +} as unknown as OpenCodeClient describe('opencode-config-apply', () => { let workDir: string @@ -79,6 +86,8 @@ describe('opencode-config-apply', () => { beforeEach(async () => { vi.clearAllMocks() locationReloadMock.mockReset().mockResolvedValue(undefined) + mcpAddMock.mockReset().mockResolvedValue(undefined) + mcpRemoveMock.mockReset().mockResolvedValue(undefined) workDir = await mkdtemp(path.join(tmpdir(), 'opencode-config-apply-')) paths.config = path.join(workDir, 'opencode.json') paths.configDir = workDir @@ -323,18 +332,50 @@ describe('opencode-config-apply', () => { expect(clearStartupErrorMock).not.toHaveBeenCalled() }) - it('applies an mcp-only change without reloading or marking a restart pending', async () => { + it('reloads an mcp-only change without touching the MCP API', async () => { await writeFile(sourcePath('opencode.json'), '{"theme":"dark"}', 'utf8') + const server = { type: 'local' as const, command: ['npx', 'local'] } const result = expectStatus(await applyOpenCodeConfigUpdate({ - content: { theme: 'dark', mcp: { local: { type: 'local' } } }, + content: { theme: 'dark', mcp: { servers: { local: server } } }, settingsService, openCodeClient, - }), 'applied') + }), 'reloaded') - expect(result.config.content).toEqual({ theme: 'dark', mcp: { local: { type: 'local' } } }) + expect(result.config.content).toEqual({ theme: 'dark', mcp: { servers: { local: server } } }) + expect(locationReloadMock).toHaveBeenCalledOnce() + expect(mcpAddMock).not.toHaveBeenCalled() + expect(mcpRemoveMock).not.toHaveBeenCalled() expect(markRestartPendingMock).not.toHaveBeenCalled() - expect(locationReloadMock).not.toHaveBeenCalled() + }) + + it('reloads an mcp-only removal without touching the MCP API', async () => { + const server = { type: 'local' as const, command: ['npx', 'local'] } + await writeFile(sourcePath('opencode.json'), JSON.stringify({ theme: 'dark', mcp: { servers: { local: server } } }), 'utf8') + + expectStatus(await applyOpenCodeConfigUpdate({ + content: { theme: 'dark', mcp: { servers: {} } }, + settingsService, + openCodeClient, + }), 'reloaded') + + expect(locationReloadMock).toHaveBeenCalledOnce() + expect(mcpRemoveMock).not.toHaveBeenCalled() + expect(mcpAddMock).not.toHaveBeenCalled() + }) + + it('reloads an mcp-only change that touches a legacy flat entry', async () => { + await writeFile(sourcePath('opencode.json'), '{"theme":"dark"}', 'utf8') + + expectStatus(await applyOpenCodeConfigUpdate({ + content: { theme: 'dark', mcp: { legacy: { type: 'local', command: ['npx', 'legacy'] } } }, + settingsService, + openCodeClient, + }), 'reloaded') + + expect(locationReloadMock).toHaveBeenCalledOnce() + expect(mcpAddMock).not.toHaveBeenCalled() + expect(mcpRemoveMock).not.toHaveBeenCalled() }) it('reloads when mcp changes alongside another key', async () => { diff --git a/backend/test/services/opencode-config-file.test.ts b/backend/test/services/opencode-config-file.test.ts index 7f161b4f2..2118bf2eb 100644 --- a/backend/test/services/opencode-config-file.test.ts +++ b/backend/test/services/opencode-config-file.test.ts @@ -63,8 +63,10 @@ vi.mock('../../src/utils/fs-safe', async (importOriginal) => { import { LEGACY_OPENCODE_CONFIG_SOURCE_NAME } from '@opencode-manager/shared' import { HEALTH_WATCH_MAX_ENTRIES, + OPENCODE_CONFIG_REDACTED_VALUE, OPENCODE_CONFIG_SEED, OpenCodeConfigConflictError, + OpenCodeConfigRedactedValueError, OpenCodeConfigShadowedRemovalError, OpenCodeConfigSnapshotError, archiveBrokenOpenCodeConfigFile, @@ -249,6 +251,162 @@ describe('opencode-config-file', () => { await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(lower) }) + it('merges only the patched paths and leaves every other field untouched', async () => { + const lower = '{\n // lower config\n "model": "lower/model"\n}\n' + const target = '{\n // target config\n "theme": "dark",\n "small_model": "small"\n}\n' + await writeFile(sourcePath('opencode.json'), lower, 'utf8') + await writeFile(sourcePath('opencode.jsonc'), target, 'utf8') + + const updated = await updateOpenCodeConfigFile({ theme: 'light' }, { mode: 'merge' }) + + expect(updated.content).toEqual({ model: 'lower/model', theme: 'light', small_model: 'small' }) + const targetContent = await readFile(sourcePath('opencode.jsonc'), 'utf8') + expect(targetContent).toContain('// target config') + expect(targetContent).toContain('"theme": "light"') + expect(targetContent).toContain('"small_model": "small"') + await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(lower) + }) + + it('merges a nested patch, creating paths that do not exist yet', async () => { + const raw = JSON.stringify({ + mcp: { + servers: { + linear: { type: 'remote', url: 'https://linear.example.com', headers: { Authorization: 'Bearer old' } }, + }, + }, + }) + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile( + { + mcp: { + servers: { + linear: { headers: { Authorization: 'Bearer new' } }, + github: { type: 'local', command: ['npx', 'github'] }, + }, + }, + }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + mcp: { + servers: { + linear: { type: 'remote', url: 'https://linear.example.com', headers: { Authorization: 'Bearer new' } }, + github: { type: 'local', command: ['npx', 'github'] }, + }, + }, + }) + const onDisk = JSON.parse(await readFile(sourcePath('opencode.json'), 'utf8')) as Record + expect(onDisk).toEqual(updated.content) + }) + + it('removes a patched path with a null value', async () => { + await writeFile(sourcePath('opencode.json'), JSON.stringify({ theme: 'dark', small_model: 's' }), 'utf8') + + const updated = await updateOpenCodeConfigFile({ small_model: null }, { mode: 'merge' }) + + expect(updated.content).toEqual({ theme: 'dark' }) + const onDisk = JSON.parse(await readFile(sourcePath('opencode.json'), 'utf8')) as Record + expect(onDisk).toEqual({ theme: 'dark' }) + }) + + it('treats a merge patch with unchanged values as no change', async () => { + const raw = '{\n // keep this comment\n "theme": "dark"\n}\n' + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile({ theme: 'dark' }, { mode: 'merge' }) + + expect(updated.rawContent).toBe(raw) + }) + + it('rejects a merge patch whose merged result is schema-invalid and writes nothing', async () => { + const raw = '{"theme":"dark"}' + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + await expect(updateOpenCodeConfigFile({ model: 5 }, { mode: 'merge' })).rejects.toBeInstanceOf(ZodError) + + await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(raw) + }) + + it('rejects a merge patch that removes a value only a lower-priority source defines', async () => { + const lower = '{"theme":"light"}' + const target = '{"model":"b"}' + await writeFile(sourcePath('opencode.json'), lower, 'utf8') + await writeFile(sourcePath('opencode.jsonc'), target, 'utf8') + + const error = await updateOpenCodeConfigFile({ theme: null }, { mode: 'merge' }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeConfigShadowedRemovalError) + expect((error as OpenCodeConfigShadowedRemovalError).paths).toEqual(['theme']) + expect((error as OpenCodeConfigShadowedRemovalError).sources).toEqual(['opencode.json']) + await expect(readFile(sourcePath('opencode.jsonc'), 'utf8')).resolves.toBe(target) + await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(lower) + }) + + it('merges into an existing nested object while deleting sibling keys with null', async () => { + const raw = JSON.stringify({ + mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { Authorization: 'Bearer old', 'X-Keep': '1' } } } }, + }) + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile( + { mcp: { servers: { linear: { headers: { Authorization: null } } } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { 'X-Keep': '1' } } } }, + }) + const onDisk = JSON.parse(await readFile(sourcePath('opencode.json'), 'utf8')) as Record + expect(onDisk).toEqual(updated.content) + }) + + it('strips nested nulls when a merge patch creates a new path', async () => { + await writeFile(sourcePath('opencode.json'), '{"theme":"dark"}', 'utf8') + + const updated = await updateOpenCodeConfigFile( + { mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { Authorization: null, 'X-Trace': 'on' } } } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + theme: 'dark', + mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { 'X-Trace': 'on' } } } }, + }) + const onDisk = await readFile(sourcePath('opencode.json'), 'utf8') + expect(onDisk).not.toContain('null') + expect(JSON.parse(onDisk)).toEqual(updated.content) + }) + + it('rejects a redacted placeholder in a merge patch and writes nothing', async () => { + const raw = '{"theme":"dark"}' + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const error = await updateOpenCodeConfigFile( + { mcp: { servers: { linear: { headers: { Authorization: OPENCODE_CONFIG_REDACTED_VALUE } } } } }, + { mode: 'merge' }, + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeConfigRedactedValueError) + expect((error as OpenCodeConfigRedactedValueError).paths).toEqual(['mcp.servers.linear.headers.Authorization']) + await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(raw) + }) + + it('rejects a redacted placeholder in replace content including array indexes and writes nothing', async () => { + const raw = '{"theme":"dark"}' + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const error = await updateOpenCodeConfigFile({ + theme: 'light', + instructions: ['keep', OPENCODE_CONFIG_REDACTED_VALUE], + }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeConfigRedactedValueError) + expect((error as OpenCodeConfigRedactedValueError).paths).toEqual(['instructions.1']) + await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(raw) + }) + it('removes an override from the target source only and reveals inherited values', async () => { const lower = JSON.stringify({ theme: 'light', model: 'a' }) await writeFile(sourcePath('opencode.json'), lower, 'utf8') diff --git a/backend/test/services/opencode-config-redact.test.ts b/backend/test/services/opencode-config-redact.test.ts new file mode 100644 index 000000000..80234ff40 --- /dev/null +++ b/backend/test/services/opencode-config-redact.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { redactOpenCodeConfigFile } from '../../src/services/opencode-config-redact' +import type { OpenCodeConfigFile } from '../../src/types/settings' + +function buildConfig(content: Record): OpenCodeConfigFile { + return { + path: '/config/opencode.jsonc', + content, + rawContent: JSON.stringify(content), + isValid: true, + updatedAt: 1, + sources: [ + { + name: 'opencode.jsonc', + path: '/config/opencode.jsonc', + content, + rawContent: JSON.stringify(content), + isValid: true, + updatedAt: 1, + }, + ], + revision: 'revision', + } +} + +describe('redactOpenCodeConfigFile', () => { + it('redacts secret-keyed values and every header and environment value while keeping keys', () => { + const redacted = redactOpenCodeConfigFile( + buildConfig({ + theme: 'dark', + providers: { example: { apiKey: 'secret-key', label: 'Example' } }, + mcp: { + servers: { + linear: { + type: 'remote', + url: 'https://linear.example.com', + headers: { Authorization: 'Bearer secret', 'X-Trace': 'trace' }, + }, + local: { type: 'local', command: ['npx', 'server'], environment: { GITHUB_TOKEN: 'token', NODE_ENV: 'production' } }, + }, + }, + }), + ) + + expect(redacted.content).toEqual({ + theme: 'dark', + providers: { example: { apiKey: '', label: 'Example' } }, + mcp: { + servers: { + linear: { + type: 'remote', + url: 'https://linear.example.com', + headers: { Authorization: '', 'X-Trace': '' }, + }, + local: { + type: 'local', + command: ['npx', 'server'], + environment: { GITHUB_TOKEN: '', NODE_ENV: '' }, + }, + }, + }, + }) + expect(redacted.redactedPaths).toEqual([ + 'mcp.servers.linear.headers.Authorization', + 'mcp.servers.linear.headers.X-Trace', + 'mcp.servers.local.environment.GITHUB_TOKEN', + 'mcp.servers.local.environment.NODE_ENV', + 'providers.example.apiKey', + ]) + }) + + it('drops the raw source text and redacts each source content', () => { + const redacted = redactOpenCodeConfigFile(buildConfig({ providers: { example: { apiKey: 'secret' } } })) + const [source] = redacted.sources + + expect('rawContent' in redacted).toBe(false) + expect(redacted.content).toEqual({ providers: { example: { apiKey: '' } } }) + expect(redacted.redactedPaths).toEqual(['providers.example.apiKey']) + expect(source?.content).toEqual({ providers: { example: { apiKey: '' } } }) + expect(source && 'rawContent' in source).toBe(false) + }) + + it('leaves a config without secrets unchanged', () => { + const redacted = redactOpenCodeConfigFile(buildConfig({ theme: 'dark', plugin: ['/plugins/probe'] })) + + expect(redacted.content).toEqual({ theme: 'dark', plugin: ['/plugins/probe'] }) + expect(redacted.redactedPaths).toEqual([]) + }) + + it('leaves numeric token settings untouched', () => { + const redacted = redactOpenCodeConfigFile( + buildConfig({ maxTokens: 8192, budgetTokens: 4096, max_tokens: 2048 }), + ) + + expect(redacted.content).toEqual({ maxTokens: 8192, budgetTokens: 4096, max_tokens: 2048 }) + expect(redacted.redactedPaths).toEqual([]) + }) + + it('does not mask server, provider, or model names that look like secret keys', () => { + const redacted = redactOpenCodeConfigFile( + buildConfig({ + mcp: { + servers: { + 'github-token': { + type: 'remote', + url: 'https://github.example.com', + headers: { Authorization: 'Bearer secret' }, + }, + }, + }, + provider: { + 'secret-provider': { + apiKey: 'provider-secret', + models: { 'token-model': { name: 'gpt' } }, + }, + }, + }), + ) + + expect(redacted.content).toEqual({ + mcp: { + servers: { + 'github-token': { + type: 'remote', + url: 'https://github.example.com', + headers: { Authorization: '' }, + }, + }, + }, + provider: { + 'secret-provider': { + apiKey: '', + models: { 'token-model': { name: 'gpt' } }, + }, + }, + }) + expect(redacted.redactedPaths).toEqual([ + 'mcp.servers.github-token.headers.Authorization', + 'provider.secret-provider.apiKey', + ]) + }) + + it('leaves non-string secret-keyed values untouched', () => { + const redacted = redactOpenCodeConfigFile( + buildConfig({ password: 1234, token: true, secret: null }), + ) + + expect(redacted.content).toEqual({ password: 1234, token: true, secret: null }) + expect(redacted.redactedPaths).toEqual([]) + }) +}) diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 3839ae65a..13860bbe9 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -224,20 +224,20 @@ describe('ocm-manager plugin', () => { expect(JSON.parse(init.body)).toEqual({ theme: 'dark' }) }) - it('sends a PUT request with a JSON body', async () => { + it('sends a PATCH request to the config route with a JSON body', async () => { const fetchMock = jsonResponse({}) vi.stubGlobal('fetch', fetchMock) const tool = await loadTool(configHome) - await runTool(tool, { action: 'request', params: { method: 'PUT', path: '/opencode-config', body: { content: { theme: 'dark' } } } }) + await runTool(tool, { action: 'request', params: { method: 'PATCH', path: '/opencode-config', body: { patch: { theme: 'dark' } } } }) expect(fetchMock).toHaveBeenCalledTimes(1) const [url, init] = fetchMock.mock.calls[0] ?? [] expect(url).toBe('http://localhost:5003/api/internal/opencode-config') - expect(init.method).toBe('PUT') + expect(init.method).toBe('PATCH') expect(init.headers.Authorization).toBe('Bearer secret-token') expect(init.headers['content-type']).toBe('application/json') - expect(JSON.parse(init.body)).toEqual({ content: { theme: 'dark' } }) + expect(JSON.parse(init.body)).toEqual({ patch: { theme: 'dark' } }) }) it('allows every route in the exported allow list', async () => { @@ -263,6 +263,7 @@ describe('ocm-manager plugin', () => { ['GET', '/repos/0/mirror/head'], ['POST', '/notifications/send'], ['DELETE', '/settings'], + ['PUT', '/opencode-config'], ] as const for (const [method, path] of deniedRoutes) { diff --git a/backend/test/shared/opencode-mcp.test.ts b/backend/test/shared/opencode-mcp.test.ts index 72e2c78a4..b6ad03a30 100644 --- a/backend/test/shared/opencode-mcp.test.ts +++ b/backend/test/shared/opencode-mcp.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { MCP_OAUTH_CALLBACK_PATH, mcpOAuthRedirectUri, + mcpServerViewsFromConfig, mcpServersFromConfig, mcpStatusByName, } from '@opencode-manager/shared/opencode' @@ -47,6 +48,47 @@ describe('mcpServersFromConfig', () => { }) }) +describe('mcpServerViewsFromConfig', () => { + it('normalizes native and legacy servers, reporting each shape and enabled state', () => { + expect( + mcpServerViewsFromConfig({ + timeout: { catalog: 5000 }, + legacy: { type: 'local', command: ['npx', 'legacy'], enabled: false }, + servers: { + remote: { type: 'remote', url: 'https://native.example.com', disabled: true }, + local: { type: 'local', command: ['npx', 'native'] }, + }, + }), + ).toEqual([ + { + name: 'legacy', + type: 'local', + command: ['npx', 'legacy'], + enabled: false, + shape: 'legacy', + }, + { + name: 'local', + type: 'local', + command: ['npx', 'native'], + enabled: true, + shape: 'servers', + }, + { + name: 'remote', + type: 'remote', + url: 'https://native.example.com', + enabled: false, + shape: 'servers', + }, + ]) + }) + + it('returns an empty list for a missing config', () => { + expect(mcpServerViewsFromConfig(undefined)).toEqual([]) + }) +}) + describe('mcpStatusByName', () => { it('maps every V2 status onto the Manager status map', () => { const servers: McpServer[] = [ diff --git a/docs/development/setup.md b/docs/development/setup.md index 1f84d40c4..49e2f1200 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -60,6 +60,7 @@ opencode-manager/ │ │ └── contexts/ # React contexts │ └── public/ # Static assets ├── shared/ # @opencode-manager/shared types and utilities +├── ocm-cli/ # ocm CLI for attaching a local OpenCode TUI to Manager repos ├── workspace/ # Runtime workspace for OpenCode ├── docs/ # Documentation ├── scripts/ # Build and utility scripts @@ -75,8 +76,8 @@ opencode-manager/ pnpm dev # Start both backend and frontend (runs setup-dev.sh first) pnpm dev:backend # Start backend only pnpm dev:frontend # Start frontend only -pnpm build # Build both packages -pnpm lint # Lint both packages +pnpm build # Build all packages +pnpm lint # Lint all packages pnpm test # Run all tests ``` diff --git a/docs/features/assistant-internal-api.md b/docs/features/assistant-internal-api.md index 8e09f55ec..bfdc24ded 100644 --- a/docs/features/assistant-internal-api.md +++ b/docs/features/assistant-internal-api.md @@ -63,7 +63,8 @@ The `path` is relative to the internal API base (for example `/settings` or `/re GET /settings PATCH /settings GET /opencode-config -PUT /opencode-config +GET /opencode-config/mcp +PATCH /opencode-config POST /assistant/reload GET /repos GET /repos/*/git-info @@ -210,22 +211,22 @@ Returns the updated settings object. ### OpenCode Configuration -The global OpenCode configuration files in the workspace `.config/opencode/` directory are the source of truth, and these endpoints are the only supported way to change them. The two sources OpenCode 2 reads are merged in order — `opencode.json`, `opencode.jsonc` — with later files overriding earlier ones. A legacy `config.json` is folded into a recognized source and archived; it is never read as a live source. The schema accepts both V1-compatible keys and native OpenCode 2 fields, so a V2-native config passes validation. The endpoint applies the same rules as the Settings UI: any semantic change is written to disk and applied to the running server with an in-place OpenCode location reload, and it is flagged as restart required only when that reload fails; comment-only edits do nothing, and changes limited to `mcp` are saved without a reload. +The global OpenCode configuration files in the workspace `.config/opencode/` directory are the source of truth, and these endpoints are the only supported way to change them. The two sources OpenCode 2 reads are merged in order — `opencode.json`, `opencode.jsonc` — with later files overriding earlier ones. A legacy `config.json` is folded into a recognized source and archived; it is never read as a live source. The schema accepts both V1-compatible keys and native OpenCode 2 fields, so a V2-native config passes validation. The endpoints apply the same rules as the Settings UI: any semantic change is written to disk and applied to the running server, and it is flagged as restart required only when that apply fails; comment-only edits do nothing, and `mcp` changes go through the same location reload, which reconnects only the servers whose configuration changed. + +The agent-facing surface is redacted: every read and write response replaces secret values with `` and omits the raw source text, a write containing `` is rejected, and `PATCH` changes only the paths it names, so an agent never has to read or reproduce the whole file. The public surface the Settings UI uses keeps full fidelity and the whole-document `PUT`. **GET `/api/internal/opencode-config`** -Read the merged persisted configuration and its source files. This is not the running instance configuration: project overrides and expanded environment values are not included. +Read the merged persisted configuration and its source files, with secret values redacted and the raw source text omitted. This is not the running instance configuration: project overrides and expanded environment values are not included. -**Response (`OpenCodeConfigFile`):** +**Response:** ```ts { path: string // Absolute path of the preferred write target - content: object // Merged configuration across all sources - rawContent: string // Raw content of the preferred write target - sources: Array<{ // Recognized source files, in merge order + content: object // Merged configuration across all sources, secrets replaced with "" + sources: Array<{ // Recognized source files, in merge order, raw content omitted name: 'opencode.json' | 'opencode.jsonc' path: string - rawContent: string content: object isValid: boolean validationIssues?: Array<{ path: string, message: string }> @@ -235,6 +236,7 @@ Read the merged persisted configuration and its source files. This is not the ru isValid: boolean // Whether every source passes schema validation validationIssues?: Array<{ path: string, message: string }> updatedAt: number // Newest source mtime + redactedPaths: string[] // Dotted paths whose values were replaced } ``` @@ -244,9 +246,35 @@ Read the merged persisted configuration and its source files. This is not the ru - `404`: No config source found - `500`: Server error +**GET `/api/internal/opencode-config/mcp`** + +List the configured MCP servers with their stored shape, enabled state, and live connection status. Header and environment values are never returned. + +**Response:** +```ts +{ + revision: string | null + servers: Array<{ + name: string + type: 'local' | 'remote' + command?: string[] // For local servers + url?: string // For remote servers + enabled: boolean + shape: 'servers' | 'legacy' // Native mcp.servers. or flat mcp. + status?: 'connected' | 'pending' | 'disabled' | 'failed' | 'needs_auth' + error?: string + }> +} +``` + +**Status Codes:** +- `200`: MCP server list returned +- `401`: Missing or invalid bearer token +- `500`: Server error + **GET `/api/internal/opencode-config/effective`** -Read the running server's configuration 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, so never copy this response into a save. +Read the running server's configuration 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, so never copy this response into a save. Secret values in `info` are replaced with ``. **Status Codes:** - `200`: Effective configuration returned @@ -254,27 +282,29 @@ Read the running server's configuration as `entries`: the configuration document - `502`: OpenCode returned an error - `503`: OpenCode server unavailable -**PUT `/api/internal/opencode-config`** +**PATCH `/api/internal/opencode-config`** + +Change only the paths the request names. Only those paths are patched into the preferred existing source (`opencode.jsonc` > `opencode.json`; a new installation gets `opencode.jsonc`); comments, unknown keys, and untouched inherited values are preserved. A `null` value removes a path. Removing a path defined only in a lower-priority source is rejected (`409`). -Read the merged configuration first, change only the keys the user asked for, and send the complete object back with its `revision`. Only changed paths are patched into the preferred existing source (`opencode.jsonc` > `opencode.json`; a new installation gets `opencode.jsonc`); comments, unknown keys, and untouched inherited values are preserved. For a raw edit, send a string as `content` together with the exact `source` name. +The public route keeps the whole-document `PUT` for the Settings UI; the internal route exposes `PATCH` instead. **Request Body:** ```ts { - content: object | string // Complete merged object, or raw text for one source + patch: object // Nested paths to set; null removes a path expectedRevision?: string // From GET; a stale value is rejected with 409 - source?: 'opencode.json' | 'opencode.jsonc' // Required for raw string edits + source?: 'opencode.json' | 'opencode.jsonc' } ``` **Response:** -Returns the refreshed `OpenCodeConfigFile`. Semantic changes are applied with an OpenCode location reload, without a restart. Adds `restartRequired: true` only when that reload fails. +Returns the refreshed redacted configuration. Semantic changes, including `mcp`, are applied with an OpenCode location reload, without a restart; only the MCP servers whose configuration changed reconnect. Adds `restartRequired: true` only when that apply fails. **Status Codes:** - `200`: Configuration written -- `400`: Invalid request body, invalid configuration, or a source file that is not valid JSON/JSONC (`sources` lists them) +- `400`: Invalid request body, invalid configuration, a source file that is not valid JSON/JSONC (`sources` lists them), or a value equal to `` (`paths` lists them) - `401`: Missing or invalid bearer token -- `409`: Stale `expectedRevision` (`expectedRevision`/`actualRevision` in the body), or the save would remove a value defined only in a lower-priority source (`paths`/`sources` in the body) +- `409`: Stale `expectedRevision` (`expectedRevision`/`actualRevision` in the body), or the patch would remove a value defined only in a lower-priority source (`paths`/`sources` in the body) - `500`: Server error ### Assistant diff --git a/docs/features/mcp.md b/docs/features/mcp.md index 421343049..47ea358c6 100644 --- a/docs/features/mcp.md +++ b/docs/features/mcp.md @@ -196,6 +196,12 @@ Remove a server: Use the refresh button in **Settings > MCP Servers** to reload the current connection status after changing a server outside the UI or completing OAuth. +### Configuration Shape + +The Manager writes and edits the OpenCode 2 shape, `mcp.servers.`, with `disabled` for the toggle. OpenCode also still accepts the older flat `mcp.` shape with `enabled` and normalizes it on load, so a configuration written by an earlier version (or by hand) keeps working. The Manager manages only `mcp.servers` entries; move a flat entry under `mcp.servers` to manage it. + +Saving an MCP change reloads the OpenCode configuration, which reconnects only the servers whose configuration changed, with `{env:}` and `{file:}` values expanded. A remote server that has gone through OAuth in the Manager holds a runtime override that takes precedence over the file, so later edits to that server take effect after an OpenCode restart from Settings. + ## Server Status Monitor server health: diff --git a/docs/features/overview.md b/docs/features/overview.md index caf34b439..b62c3f669 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -88,6 +88,14 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI [Learn more →](mobile.md) +### Appearance & Themes + +- **Appearance** - Switch between light, dark, or system to match your device +- **Theme** - Choose the Manager palette or one of 36 bundled OpenCode color themes +- **Server-Stored** - Appearance preferences are saved on the Manager server, not only in your browser + +Both pickers live under **Settings → General**. + ### Push Notifications - **Background Alerts** - Receive notifications when the app is closed diff --git a/docs/index.md b/docs/index.md index 136f7ad7a..7d6c81a4e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,7 @@ OpenCode Manager runs as a pnpm workspace: - A supervised OpenCode server handles agent sessions while the backend proxies API calls and streams events over SSE. - The React/Vite frontend uses React Router and TanStack Query to render repositories, sessions, schedules, settings, and mobile navigation. - The shared package keeps config, schemas, and TypeScript types aligned between backend and frontend. +- The `ocm-cli/` package provides the `ocm` CLI that attaches a local OpenCode TUI to a Manager-hosted repo. ## Key Features @@ -71,6 +72,7 @@ OpenCode Manager runs as a pnpm workspace: - `backend/` — Bun + Hono API routes, services, database migrations, auth, schedules, and OpenCode integration. - `frontend/` — React + Vite app, pages, components, hooks, API clients, stores, contexts, and PWA assets. - `shared/` — Workspace package for schemas, types, config, and utilities. +- `ocm-cli/` — `ocm` CLI that attaches a local OpenCode TUI to a Manager-hosted repo. - `docs/` — MkDocs Material documentation. - `scripts/`, `Dockerfile`, `docker-compose.yml` — Setup, build, and deployment support. diff --git a/frontend/src/api/mcp.ts b/frontend/src/api/mcp.ts index cee66fd40..318475e6f 100644 --- a/frontend/src/api/mcp.ts +++ b/frontend/src/api/mcp.ts @@ -5,7 +5,6 @@ import { isMcpServerNotFoundError, mcpStatusByName, openCodeLocation, - type McpServerConfig, type McpStatusMap, } from '@opencode-manager/shared/opencode' @@ -28,10 +27,6 @@ export const mcpApi = { return mcpStatusByName(data) }, - async addServer(name: string, config: McpServerConfig): Promise { - await callOpenCode((api) => api.mcp.add({ server: name, config })) - }, - async removeServer(name: string): Promise { await callOpenCode(async (api) => { try { diff --git a/frontend/src/components/settings/AddMcpServerDialog.test.tsx b/frontend/src/components/settings/AddMcpServerDialog.test.tsx index 426c58043..9329bc4a4 100644 --- a/frontend/src/components/settings/AddMcpServerDialog.test.tsx +++ b/frontend/src/components/settings/AddMcpServerDialog.test.tsx @@ -8,11 +8,9 @@ import { makeOpenCodeConfigFile } from '@/test/fixtures/opencode-config' const { mockGetOpenCodeConfig, mockUpdateOpenCodeConfig, - mockAddServerAsync, } = vi.hoisted(() => ({ mockGetOpenCodeConfig: vi.fn(), mockUpdateOpenCodeConfig: vi.fn(), - mockAddServerAsync: vi.fn(), })) vi.mock('@/api/settings', () => ({ @@ -22,10 +20,6 @@ vi.mock('@/api/settings', () => ({ }, })) -vi.mock('@/hooks/useMcpServers', () => ({ - useMcpServers: () => ({ addServerAsync: mockAddServerAsync, isAddingServer: false }), -})) - vi.mock('@/lib/toast', () => ({ showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, })) @@ -53,7 +47,6 @@ describe('AddMcpServerDialog', () => { vi.clearAllMocks() mockGetOpenCodeConfig.mockResolvedValue(config) mockUpdateOpenCodeConfig.mockResolvedValue(config) - mockAddServerAsync.mockResolvedValue(undefined) }) it('issues exactly one config update through the owner callback and never writes directly', async () => { @@ -78,7 +71,6 @@ describe('AddMcpServerDialog', () => { }, }, }) - expect(mockAddServerAsync).toHaveBeenCalledTimes(1) }) it('writes a remote server with V2 OAuth keys and the Manager callback', async () => { @@ -107,7 +99,6 @@ describe('AddMcpServerDialog', () => { timeout: { catalog: 9000, execution: 9000 }, } expect(onUpdate).toHaveBeenCalledWith({ mcp: { servers: { 'remote-tools': serverConfig } } }) - expect(mockAddServerAsync).toHaveBeenCalledWith({ name: 'remote-tools', config: serverConfig }) }) it('passes only the merged content to onUpdate', async () => { diff --git a/frontend/src/components/settings/AddMcpServerDialog.tsx b/frontend/src/components/settings/AddMcpServerDialog.tsx index 258113137..f65c2665b 100644 --- a/frontend/src/components/settings/AddMcpServerDialog.tsx +++ b/frontend/src/components/settings/AddMcpServerDialog.tsx @@ -6,7 +6,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from ' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Loader2 } from 'lucide-react' import { Switch } from '@/components/ui/switch' -import { useMcpServers } from '@/hooks/useMcpServers' import { settingsApi } from '@/api/settings' import { mcpOAuthRedirectUri, @@ -40,7 +39,6 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe const [oauthScope, setOauthScope] = useState('') const queryClient = useQueryClient() - const { addServerAsync, isAddingServer } = useMcpServers() const buildTimeout = (): McpTimeoutConfig | undefined => { const parsed = parseInt(timeout) @@ -108,10 +106,6 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe servers: { ...(mcp.servers as Record | undefined), [serverId]: mcpServerConfig }, }, }) - - if (enabled) { - await addServerAsync({ name: serverId, config: mcpServerConfig }) - } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) @@ -154,7 +148,7 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe onOpenChange(false) } - const isPending = addMcpServerMutation.isPending || isAddingServer + const isPending = addMcpServerMutation.isPending return ( diff --git a/frontend/src/components/settings/McpManager.test.tsx b/frontend/src/components/settings/McpManager.test.tsx index e2104c8d8..f026c0ade 100644 --- a/frontend/src/components/settings/McpManager.test.tsx +++ b/frontend/src/components/settings/McpManager.test.tsx @@ -23,8 +23,6 @@ vi.mock('@/hooks/useMcpServers', () => ({ disconnect: vi.fn(), removeAuthAsync: vi.fn(), isRemovingAuth: false, - addServerAsync: vi.fn(), - isAddingServer: false, }), })) @@ -81,8 +79,8 @@ describe('McpManager', () => { await deleteServer(user, 'Configured') - await waitFor(() => expect(mockRemoveServer).toHaveBeenCalledWith('configured')) - expect(onUpdate).toHaveBeenCalledWith({ mcp: { servers: {} } }) + await waitFor(() => expect(onUpdate).toHaveBeenCalledWith({ mcp: { servers: {} } })) + expect(mockRemoveServer).not.toHaveBeenCalled() }) it('skips the config update for a server that is not in mcp.servers', async () => { @@ -96,7 +94,7 @@ describe('McpManager', () => { expect(onUpdate).not.toHaveBeenCalled() }) - it('surfaces the shadowed-removal message from the backend', async () => { + it('surfaces the config save error when deleting a configured server', async () => { const message = 'Cannot remove mcp.servers.configured: defined in opencode.json, not in opencode.jsonc' const onUpdate = vi.fn<(content: Record) => Promise>().mockRejectedValue(new FetchError(message, 409)) const user = userEvent.setup() diff --git a/frontend/src/components/settings/McpManager.tsx b/frontend/src/components/settings/McpManager.tsx index 81d3313f0..9a6c4d825 100644 --- a/frontend/src/components/settings/McpManager.tsx +++ b/frontend/src/components/settings/McpManager.tsx @@ -62,6 +62,7 @@ export function McpManager({ config, onUpdate }: McpManagerProps) { ...config.content, mcp: withoutMcpServer(config.content.mcp, serverId), }) + return } await mcpApi.removeServer(serverId) diff --git a/frontend/src/components/settings/OpenCodeConfigManager.test.tsx b/frontend/src/components/settings/OpenCodeConfigManager.test.tsx index 5ff333c37..844eff83d 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.test.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.test.tsx @@ -15,7 +15,6 @@ const { mockListManagedSkills, mockListOpenCodeDirectoryFiles, mockGetAgentsMd, - mockAddServerAsync, healthState, } = vi.hoisted(() => ({ mockGetOpenCodeConfig: vi.fn(), @@ -26,7 +25,6 @@ const { mockListManagedSkills: vi.fn(), mockListOpenCodeDirectoryFiles: vi.fn(), mockGetAgentsMd: vi.fn(), - mockAddServerAsync: vi.fn(), healthState: { data: { opencode: 'healthy', opencodeRestartPending: false } as Record }, })) @@ -41,9 +39,6 @@ vi.mock('@/hooks/useMcpServers', () => ({ isError: false, error: null, refetch: vi.fn(), - addServer: vi.fn(), - addServerAsync: mockAddServerAsync, - isAddingServer: false, connect: vi.fn(), connectAsync: vi.fn(), isConnecting: false, @@ -123,7 +118,6 @@ describe('OpenCodeConfigManager', () => { mockUpdateOpenCodeConfig.mockResolvedValue(defaultConfig) mockRestartOpenCodeServer.mockResolvedValue({ success: true, message: 'ok' }) mockGetActiveOpenCodeSessions.mockResolvedValue({ count: 2, sessions: [] }) - mockAddServerAsync.mockResolvedValue(undefined) }) it('shows uploaded command and agent directory files in settings', async () => { @@ -408,7 +402,6 @@ describe('OpenCodeConfigManager', () => { await waitFor(() => expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1)) const [payload] = mockUpdateOpenCodeConfig.mock.calls[0] expect(payload.expectedRevision).toBe('rev-A') - expect(mockAddServerAsync).toHaveBeenCalledTimes(1) }) it('sends the revision from the previous save on the next structured save', async () => { diff --git a/frontend/src/hooks/useMcpServers.test.tsx b/frontend/src/hooks/useMcpServers.test.tsx index 03cd42f90..c0309b558 100644 --- a/frontend/src/hooks/useMcpServers.test.tsx +++ b/frontend/src/hooks/useMcpServers.test.tsx @@ -6,7 +6,6 @@ import { useMcpServers } from './useMcpServers' const mocks = vi.hoisted(() => ({ getStatus: vi.fn(), - addServer: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), startAuth: vi.fn(), @@ -16,7 +15,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/api/mcp', () => ({ mcpApi: { getStatus: mocks.getStatus, - addServer: mocks.addServer, connect: mocks.connect, disconnect: mocks.disconnect, startAuth: mocks.startAuth, @@ -35,7 +33,7 @@ describe('useMcpServers', () => { beforeEach(() => { vi.clearAllMocks() mocks.getStatus.mockResolvedValue({}) - mocks.addServer.mockResolvedValue(undefined) + mocks.connect.mockResolvedValue(undefined) }) const createWrapper = (queryClient: QueryClient) => @@ -50,10 +48,7 @@ describe('useMcpServers', () => { const { result } = renderHook(() => useMcpServers(), { wrapper: createWrapper(queryClient) }) await act(async () => { - await result.current.addServerAsync({ - name: 'server', - config: { type: 'local', command: ['echo'] }, - }) + await result.current.connectAsync('server') }) const predicateCall = invalidateQueries.mock.calls.find((call) => { diff --git a/frontend/src/hooks/useMcpServers.ts b/frontend/src/hooks/useMcpServers.ts index ddbc524da..264f3d006 100644 --- a/frontend/src/hooks/useMcpServers.ts +++ b/frontend/src/hooks/useMcpServers.ts @@ -1,6 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { mcpApi } from '@/api/mcp' -import type { McpServerConfig } from '@/api/mcp' import { invalidateSessionCaches } from '@/lib/queryInvalidation' import { showToast as toast } from '@/lib/toast' @@ -14,19 +13,6 @@ export function useMcpServers() { staleTime: 2000, }) - const addServerMutation = useMutation({ - mutationFn: ({ name, config }: { name: string; config: McpServerConfig }) => - mcpApi.addServer(name, config), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) - invalidateSessionCaches(queryClient) - toast.success('MCP server added successfully') - }, - onError: (error: Error) => { - toast.error(`Failed to add MCP server: ${error.message}`) - }, - }) - const connectMutation = useMutation({ mutationFn: (name: string) => mcpApi.connect(name), onSuccess: () => { @@ -78,10 +64,6 @@ export function useMcpServers() { error: statusQuery.error, refetch: statusQuery.refetch, - addServer: addServerMutation.mutate, - addServerAsync: addServerMutation.mutateAsync, - isAddingServer: addServerMutation.isPending, - connect: connectMutation.mutate, connectAsync: connectMutation.mutateAsync, isConnecting: connectMutation.isPending, diff --git a/shared/src/opencode/index.ts b/shared/src/opencode/index.ts index 2bdea9c06..2d5cb4f4e 100644 --- a/shared/src/opencode/index.ts +++ b/shared/src/opencode/index.ts @@ -53,12 +53,14 @@ export { formatOpenCodeModelRef, parseOpenCodeModelRef } from './modelRef' export { MCP_OAUTH_CALLBACK_PATH, mcpOAuthRedirectUri, + mcpServerViewsFromConfig, mcpServersFromConfig, mcpStatusByName, } from './mcp' export type { McpServerConfig, + McpServerView, McpStatus, McpStatusMap, McpTimeoutConfig, diff --git a/shared/src/opencode/mcp.ts b/shared/src/opencode/mcp.ts index 1ab2feb4f..bcd39a6be 100644 --- a/shared/src/opencode/mcp.ts +++ b/shared/src/opencode/mcp.ts @@ -31,6 +31,45 @@ export function mcpServersFromConfig(mcp: unknown): Record isMcpServerConfig(entry[1]))) } +export type McpServerShape = 'servers' | 'legacy' + +export interface McpServerView { + name: string + type: 'local' | 'remote' + command?: string[] + url?: string + enabled: boolean + shape: McpServerShape +} + +function toMcpServerView( + name: string, + config: McpServerConfig, + enabled: boolean, + shape: McpServerShape, +): McpServerView { + return { + name, + type: config.type, + ...(config.type === 'local' ? { command: config.command } : { url: config.url }), + enabled, + shape, + } +} + +export function mcpServerViewsFromConfig(mcp: unknown): McpServerView[] { + if (!isRecord(mcp)) return [] + const nativeViews = Object.entries(mcpServersFromConfig(mcp)).map(([name, config]) => + toMcpServerView(name, config, config.disabled !== true, 'servers'), + ) + const legacyViews = Object.entries(mcp).flatMap(([name, value]) => { + if (name === 'servers' || name === 'timeout' || !isMcpServerConfig(value)) return [] + const enabled = (value as { enabled?: unknown }).enabled !== false + return [toMcpServerView(name, value, enabled, 'legacy')] + }) + return [...nativeViews, ...legacyViews].sort((left, right) => left.name.localeCompare(right.name)) +} + export function mcpStatusByName(servers: McpServer[]): McpStatusMap { return Object.fromEntries( servers.map((server) => [ diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index d8f0d2d0d..828642670 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -422,3 +422,10 @@ export const UpdateOpenCodeConfigRequestSchema = z.object({ source: OpenCodeConfigSourceNameSchema.optional(), expectedRevision: z.string().optional(), }); + +export const UpdateOpenCodeConfigPatchRequestSchema = z.object({ + patch: z.record(z.string(), z.unknown()), + source: OpenCodeConfigSourceNameSchema.optional(), + expectedRevision: z.string().optional(), +}); +