feat(settings): serve every registered namespace and key plugin cards on it
A plugin that registered a settings namespace could not reach the browser configuration page: the api-proxy filtered every read and gated every write through two hardcoded namespace lists, and the plugin configuration section rendered an unordered list of cards carrying an opaque id rather than the namespace they edit. Both gates lived in this repository, so a user-authored plugin was configurable only by hand-editing settings.yaml. The proxy now serves whatever ctx.settings.describe() returns and adds no boundary of its own; a name no registration answers folds into the seam's own settings-rejected, and the settings-not-exposed code retires. The settings seam is untouched: which client may read a namespace, and which page renders it, are facts about consumers. settings.plugin.item becomes a keyed slot whose key is the namespace a card edits, following tool.call.toolview. The section reads describe once and dispatches the intersection of the slot ledger and the served set, so a namespace another surface owns renders nothing without declaring anything, and a card for an uncomposed plugin is never dispatched.
This commit is contained in:
@@ -321,12 +321,11 @@ describe('settings domain', () => {
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
|
||||
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane remains opt-in, so a
|
||||
// future internal plugin cannot become remotely configurable just by
|
||||
// registering; locale, permission, conversation, theme, and the product
|
||||
// onboarding namespace are intentionally admitted by this surface.
|
||||
it('serves every registered namespace, including one this repository never named', async () => {
|
||||
// Registering IS the exposure: a plugin distributed outside this
|
||||
// repository configures itself from the browser without a change here.
|
||||
// The plane stays loopback-only and secret-redacted, and which surface
|
||||
// renders a namespace is the browser's decision, not this proxy's.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
@@ -357,8 +356,8 @@ describe('settings domain', () => {
|
||||
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual([
|
||||
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
|
||||
'bash', 'agent-loop', 'web-search-deepseek',
|
||||
'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale',
|
||||
'ui-conversation', 'bash', 'agent-loop', 'web-search-deepseek',
|
||||
])
|
||||
const permission = expectOk(await api.settings.mutate(request({
|
||||
ns: 'permission',
|
||||
@@ -396,16 +395,13 @@ describe('settings domain', () => {
|
||||
})))
|
||||
expect(webSearch.value).toEqual({ baseURL: 'https://search.test/v1' })
|
||||
|
||||
for (const response of [
|
||||
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
|
||||
await api.settings.replace(request({ ns: 'some-other-plugin', section: {} })),
|
||||
]) {
|
||||
const error = expectErr(response)
|
||||
expect(error.code).toBe('settings-not-exposed')
|
||||
expect(error.details).toEqual({ ns: 'some-other-plugin' })
|
||||
}
|
||||
// The write never reached the seam.
|
||||
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
|
||||
const other = expectOk(await api.settings.update(request({
|
||||
ns: 'some-other-plugin',
|
||||
patch: { secretPath: '/etc/shadow' },
|
||||
})))
|
||||
expect(other.value).toEqual({ secretPath: '/etc/shadow' })
|
||||
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value)
|
||||
.toEqual({ secretPath: '/etc/shadow' })
|
||||
})
|
||||
|
||||
it('serves product preference namespaces without invalidating the model catalog', async () => {
|
||||
@@ -445,13 +441,17 @@ describe('settings domain', () => {
|
||||
.toEqual({ default: 'minimal' })
|
||||
})
|
||||
|
||||
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
|
||||
it('keeps serving a provider namespace whose directory entry is gone', async () => {
|
||||
// The configurable-provider directory says what the Models page can offer,
|
||||
// not what a user may configure: a dormant route's stored section is still
|
||||
// theirs to edit, and losing the entry must not strand it.
|
||||
const ctx = await harness({ configurableProviders: false })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces).toEqual([])
|
||||
expect(expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).code)
|
||||
.toBe('settings-not-exposed')
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
||||
.toEqual(['llm-deepseek'])
|
||||
expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).value)
|
||||
.toMatchObject({ baseURL: 'https://x' })
|
||||
})
|
||||
|
||||
it('forwards a provider settings change for model-catalog consumers', async () => {
|
||||
@@ -551,19 +551,18 @@ describe('settings domain', () => {
|
||||
expect(error.details).toEqual({ ns })
|
||||
})
|
||||
|
||||
it('answers an unregistered namespace exactly like an unexposed one', async () => {
|
||||
// Deliberately indistinguishable: separating "does not exist" from
|
||||
// "exists but is not yours to configure" would let a caller enumerate the
|
||||
// registered namespaces one probe at a time.
|
||||
it('answers an unregistered namespace as the seam does, and a malformed one alike', async () => {
|
||||
// A name no registration answers and a name no registration could answer
|
||||
// fold into the same rejection: the proxy adds no boundary of its own, so
|
||||
// the seam's own refusal is the whole answer.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} })))
|
||||
const unexposed = expectErr(await api.settings.update(request({ ns: 'some-other-plugin', patch: {} })))
|
||||
expect(unknown.code).toBe('settings-not-exposed')
|
||||
expect(unexposed.code).toBe(unknown.code)
|
||||
expect(unexposed.message.replace('some-other-plugin', 'unknown-ns')).toBe(unknown.message)
|
||||
const malformed = expectErr(await api.settings.update(request({ ns: 'Not A Namespace', patch: {} })))
|
||||
expect(unknown.code).toBe('settings-rejected')
|
||||
expect(unknown.message).toContain('is not registered')
|
||||
expect(malformed.code).toBe(unknown.code)
|
||||
})
|
||||
|
||||
it('maps a read-only provider refusal onto the same rejection', async () => {
|
||||
|
||||
Reference in New Issue
Block a user