@@ -8,7 +8,7 @@ import type { Context } from '@deepseek-ai/cordis'
88import type { IncomingMessage , ServerResponse } from 'node:http'
99import z from '@deepseek-ai/schemastery'
1010import { credentialRef } from '@deepseek-ai/dsh-credentials'
11- import { settingsNamespace , type SettingsRegisterOptions , type SettingsScope } from '@deepseek-ai/dsh-settings'
11+ import { settingsNamespace , SettingsProvider , type SettingsRegisterOptions , type SettingsScope } from '@deepseek-ai/dsh-settings'
1212import { KbClient } from './client.js'
1313import { registerSkill } from './skill.js'
1414import { createKbTools } from './tools.js'
@@ -40,6 +40,16 @@ const CREDENTIAL_SEEDS = [
4040 [ 'defaultChatAgentId' , 'BAILIAN_DEFAULT_CHAT_AGENT_ID' ] ,
4141] as const
4242
43+ /**
44+ * Every {@link Config} field the bridge route accepts. A static allowlist,
45+ * NOT `key in current()`: optional fields with no default and no base
46+ * (the default service ids) vanish from the resolved config once cleared,
47+ * and a membership test against it would silently drop their next write.
48+ */
49+ const CONFIG_FIELDS = new Set < string > ( [
50+ 'workspaceId' , 'endpointHost' , 'defaultRetrieveAgentId' , 'defaultChatAgentId' , 'agentVersion' , 'chatTimeoutMs' ,
51+ ] )
52+
4353/**
4454 * One-time migration: before this section existed, the workspace and
4555 * default-service ids lived only as credentials, which the wire never echoes.
@@ -111,7 +121,10 @@ export function apply(ctx: Context, config: Config): void {
111121 // browser) and the scope handle the credential migration writes through.
112122 let current : ( ) => Config = ( ) => config
113123 let scope : SettingsScope < Config > | undefined
124+ /** The settings provider, captured for `mutate` (path-level unset) access. */
125+ let settings : SettingsProvider | undefined
114126 ctx . inject ( [ 'settings' ] , ( sctx ) => {
127+ settings = sctx . settings
115128 // `expose` is the wire opt-in the harness documents as deferred work; the
116129 // assertion keeps this compiling against pristine upstream types, which do
117130 // not declare it yet. Until upstream lands it the option is ignored and
@@ -174,21 +187,20 @@ export function apply(ctx: Context, config: Config): void {
174187 // Bridge routes let the browser settings page read and write the resolved
175188 // section without riding the settings wire (which requires an apiproxy
176189 // allowlist entry the composition does not grant out-of-tree namespaces).
190+ // GET and POST share one exact-route registration: the webServer map keys
191+ // on (kind, path), so two registrations for the same path throw
192+ // "duplicate route" and the second handler silently replaces the first.
177193 ctx . inject ( [ 'webServer' ] , ( wctx ) => {
178- wctx . effect ( ( ) => wctx . webServer . register ( {
179- kind : 'exact' ,
180- path : '/bailian-kb/settings' ,
181- handler : ( _req : IncomingMessage , res : ServerResponse ) => {
182- sendJson ( res , 200 , current ( ) )
183- } ,
184- } ) , 'tool-bailian-kb: settings GET route' )
185-
186194 wctx . effect ( ( ) => wctx . webServer . register ( {
187195 kind : 'exact' ,
188196 path : '/bailian-kb/settings' ,
189197 handler : async ( req : IncomingMessage , res : ServerResponse ) => {
198+ if ( req . method === 'GET' || req . method === 'HEAD' ) {
199+ sendJson ( res , 200 , current ( ) )
200+ return
201+ }
190202 if ( req . method !== 'POST' ) {
191- sendJson ( res , 405 , { error : 'use POST' } )
203+ sendJson ( res , 405 , { error : 'use GET or POST' } )
192204 return
193205 }
194206 if ( ! scope ) {
@@ -209,27 +221,30 @@ export function apply(ctx: Context, config: Config): void {
209221 const patch : Record < string , unknown > = { }
210222 const removals = new Set < string > ( )
211223 for ( const [ key , value ] of Object . entries ( body as Record < string , unknown > ) ) {
212- if ( ! ( key in current ( ) ) ) continue
224+ if ( ! CONFIG_FIELDS . has ( key ) ) continue
213225 if ( value === null ) { removals . add ( key ) ; continue }
214226 patch [ key ] = value
215227 }
216228 try {
217- if ( removals . size > 0 ) {
218- // Remove fields from the user section by replacing it wholesale
219- // with the current resolved config minus the removed keys.
220- // Absent keys re-inherit the entry base and schema defaults.
221- const next = { ...current ( ) , ...patch }
222- for ( const key of removals ) delete ( next as Record < string , unknown > ) [ key ]
223- await scope . replace ( next )
224- } else if ( Object . keys ( patch ) . length > 0 ) {
225- await scope . update ( patch )
229+ // Apply non-removal patches first (scope.update merges into the user
230+ // layer without disturbing other fields).
231+ if ( Object . keys ( patch ) . length > 0 ) await scope . update ( patch )
232+ // Remove fields via path-level unset ops: this deletes the key from
233+ // the user layer so it re-inherits the entry base and schema defaults.
234+ // Using scope.replace() with the resolved config would bake defaults
235+ // (endpointHost, chatTimeoutMs) and entry values into the user layer,
236+ // shadowing future entry changes and polluting the stored document.
237+ if ( removals . size > 0 && settings ) {
238+ for ( const key of removals ) {
239+ await settings . mutate ( SETTINGS_NS , [ { op : 'unset' , path : [ key ] } ] )
240+ }
226241 }
227242 sendJson ( res , 200 , scope . get ( ) )
228243 } catch ( err ) {
229244 sendJson ( res , 500 , { error : err instanceof Error ? err . message : 'settings write failed' } )
230245 }
231246 } ,
232- } ) , 'tool-bailian-kb: settings POST route' )
247+ } ) , 'tool-bailian-kb: settings bridge route' )
233248 } )
234249}
235250
0 commit comments