diff --git a/README.md b/README.md index a02a09d..fa99f9b 100644 --- a/README.md +++ b/README.md @@ -188,12 +188,13 @@ Honest boundaries, because each one is a decision rather than an oversight: Verified: -- 45 tests (`npm test`) over the scope algebra, the policy state, and the guard — - including that containment is decided on canonical targets (a symlink inside - the facet pointing outside it is refused), that a prefix is not a parent - (`/a/room` does not contain `/a/roommate`), that a malformed tool call never - makes the guard throw, and that every `facet` result satisfies the tool's own - declared output schema. +- 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and + the browser half — including that containment is decided on canonical targets + (a symlink inside the facet pointing outside it is refused), that a prefix is + not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool + call never makes the guard throw, that a live `mode` edit decides the next call + without the guard being remounted, and that every `facet` result satisfies the + tool's own declared output schema. - The bundle's patch layers parse, merge and land correctly against the real profile: `dsh --profile web --patch ./cordis.patch.yml --patch ./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`, @@ -263,14 +264,29 @@ it. ``` index.js host row: the policy, published as ctx.facetPolicy +client/client.js browser half: the settings card on the Plugins page lib/policy.js the stateful half — scope resolution, grants lib/scope.js the pure half — argument extraction, text, reasons lib/guard.js preset row: rule, binding, tool, pre-execute guard presets/facet.patch.yml the `facet` agent preset presets/standard-compaction.patch.yml optional: the same budget for `standard` -test/ 45 tests +test/ 53 tests ``` +`client/client.js` is a browser half rather than a module the host imports, and +it is not optional decoration. The Plugins page does not build a card from a +`Config` schema — it draws only what browser halves register into the slots it +declares. `volatile()` on the host schema makes the namespace *servable* +(`settings.describe()` drops an entry with none, and the card registers only +while the namespace is served), but a schema alone puts nothing on the page. + +The two keys in that file are deliberately different strings, and that is the +easy mistake to make here: the settings **document** is filed under the profile +entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the +**card** is keyed by the package name (`dsh-facet`). Every sibling plugin +happens to name them identically, so binding the form to the wrong one produces +a card that renders, saves, and writes nowhere. `test/client.test.js` pins both. + ## Also in this repository - [`facet-preset.md`](../facet-preset.md) — the design note this was built from, diff --git a/client/client.js b/client/client.js new file mode 100644 index 0000000..cb7094c --- /dev/null +++ b/client/client.js @@ -0,0 +1,291 @@ +/** + * dsh-facet — the browser half. + * + * **Why this file exists when the host half already declares the schema.** The + * Plugins page does not generate a card from a `Config` schema. It draws whatever + * browser halves register into the slots it declares, and `plugins.bundle.config` + * is the seat for one package's own configuration. Marking a field `volatile()` + * makes the Host *serve* that namespace — `settings.describe()` drops an entry + * with no volatile field, and `configForms.whileServed()` below refuses to + * register a page the Host does not serve — but serving it is not drawing it. No + * browser half means no card, however the schema is marked. + * + * **The two keys below are not the same string, and that is the trap.** Every + * other plugin in this installation happens to name them identically, so the + * distinction never shows up until it does: + * + * * the settings DOCUMENT is filed under the plugin's profile entry id — + * `facet-policy`, the `id` of this bundle's row in `cordis.patch.yml`. + * Exporting `Config` from `index.js` is the registration; this is the key + * `configForms.get()` addresses. + * * the CARD is filed under the bundle's PACKAGE NAME — `dsh-facet`. That is + * what the Plugins page dispatches `plugins.bundle.config` by, and it goes on + * the bundle's own page between its description and its rows. + * + * Bind the form to the wrong one and the card renders, saves, and writes nowhere. + * + * **Loadable without a bundler.** `react` and the primitives package are both in + * the boot seed table; the settings document and the slot ledgers arrive as + * Cordis services or slot props. Nothing here reaches into another plugin's + * bundle. + */ + +window.__ModuleLoader__.load({ + id: 'dsh-facet', + factory: (require) => { + var module = { exports: {} } + var exports = module.exports + + const react = require('react') + const primitives = require('@deepseek-ai/dsh-client-ui-primitives') + + const { SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives + + const h = react.createElement + + /** The settings document's key: this bundle's row id, not its package name. */ + const SETTINGS_ENTRY = 'facet-policy' + + /** The card's key: the bundle's package name, not its row id. */ + const BUNDLE_CONFIG_KEY = 'dsh-facet' + + /** The modes the host schema accepts, in the order the control offers them. */ + const MODES = ['ask', 'deny', 'off'] + + const TITLE = 'Facet' + const DESCRIPTION = 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.' + + const OVERRIDDEN_LABEL = 'Overridden' + const RESET_LABEL = 'Reset to default' + + const MODE_LABELS = { ask: 'Ask', deny: 'Deny', off: 'Off' } + const MODE_OPTIONS = MODES.map((value) => ({ value, label: MODE_LABELS[value] })) + + const labelStyle = { + display: 'block', + marginBottom: '6px', + fontSize: '13px', + fontWeight: 500, + color: 'var(--dsw-alias-label-primary)', + } + const hintStyle = { + margin: '6px 0 0', + fontSize: '12px', + lineHeight: '18px', + color: 'var(--dsw-alias-label-tertiary)', + } + const badgeStyle = { + marginLeft: '8px', + fontSize: '11px', + fontWeight: 400, + color: 'var(--dsw-alias-label-tertiary)', + } + + /** + * A finite-choice field, staged as the chosen value. + * + * The primitives ship `settingsNumberField` and `settingsTextField` and + * deliberately stop there: only the owning plugin knows what its own + * vocabulary is. An unrecognised draft returns `undefined`, which marks the + * field invalid and blocks the save rather than quietly coercing a typo into + * a mode the schema would reject at the Host anyway. + * + * @param {string} field - field name inside the namespace section. + * @param {readonly string[]} choices - the values the host schema accepts. + */ + function settingsChoiceField(field, choices) { + return { + field, + format: (value) => (typeof value === 'string' && choices.includes(value) ? value : ''), + parse: (text) => { + const normalised = String(text).trim().toLowerCase() + if (normalised === '') return { kind: 'clear' } + if (choices.includes(normalised)) return { kind: 'set', value: normalised } + return undefined + }, + } + } + + /** + * A whole-list field: comma or newline separated in the control, an array of + * non-empty strings in the document. + * + * Empty stages a clear rather than an empty array. The schema's own default + * is `[]`, so writing one would shadow a deployment's composed default with + * nothing and leave the field marked overridden when the user meant to + * remove their override. + * + * @param {string} field - field name inside the namespace section. + */ + function settingsListField(field) { + return { + field, + format: (value) => (Array.isArray(value) ? value.join(', ') : ''), + parse: (text) => { + const parts = String(text) + .split(/[,\n]/) + .map((part) => part.trim()) + .filter((part) => part !== '') + return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts } + }, + } + } + + /** The facet page's staged form over the composed `facet-policy` entry. */ + var FacetCardController = class { + /** + * @param {object} scope - the shared configuration form for `facet-policy`. + */ + constructor(scope) { + this.form = new SettingsFormModel(scope, [ + settingsChoiceField('mode', MODES), + settingsListField('extraRoots'), + ]) + this.store = this.form.bind(() => this.projection()) + } + + /** @returns {object} the shell's shared state plus this card's two controls. */ + projection() { + return { + ...this.form.shell(), + mode: this.form.field('mode'), + extraRoots: this.form.field('extraRoots'), + } + } + + /** + * Build the face the slot registration injects. + * + * The `hooks` key becomes the component's `useFacetCard` prop — the slot + * runtime turns each entry into a bound hook — and the actions supply + * `edit`, `resetField`, `save` and `discard` beside it. + * + * @returns {object} the snapshot hook and the form actions. + */ + inject() { + return { hooks: { facetCard: this.store }, ...this.form.actions() } + } + + /** Release the form subscription. */ + dispose() { + this.form.dispose() + } + } + + /** + * Render the facet card's one-liner or its settings form, as the page asks. + * + * @param {object} props - the view asked for, the bound snapshot, and the form actions. + */ + function FacetCard(props) { + if (props.view === 'summary') return DESCRIPTION + + const state = props.useFacetCard((snapshot) => snapshot) + const disabled = !state.writable || state.saving + + // The mode is editable as a segmented control rather than through + // `SettingsValueField`, because a three-value vocabulary in a free-text box + // invites a typo the field can only answer with "invalid". It is still the + // same staged draft underneath — the control calls the form's own `edit`, + // so nothing is written until Save. + const mode = h( + 'div', + { key: 'mode' }, + h( + 'label', + { style: labelStyle, htmlFor: 'facet-mode' }, + 'Enforcement mode', + state.mode.overridden ? h('span', { style: badgeStyle }, OVERRIDDEN_LABEL) : null, + ), + h(SegmentedControl, { + id: 'facet-mode', + value: state.mode.text === '' ? 'ask' : state.mode.text, + options: MODE_OPTIONS, + onChange: (next) => props.edit('mode', next), + label: 'Enforcement mode', + disabled, + }), + h( + 'p', + { style: hintStyle }, + 'Ask puts each call outside the facet to you as a question. Deny refuses it. Off disables enforcement while leaving the preset in place — useful for telling a scoping problem from an unrelated one.', + ), + ) + + const extraRoots = h(SettingsValueField, { + key: 'extraRoots', + id: 'facet-extra-roots', + label: 'Standing grants', + hint: 'Paths always in scope, in every session — shared types, a common test harness. Separate them with commas. Relative paths resolve against each session\u2019s own directory, so the same list is meaningful in every workspace.', + text: state.extraRoots.text, + overridden: state.extraRoots.overridden, + invalid: state.extraRoots.invalid, + overriddenLabel: OVERRIDDEN_LABEL, + resetLabel: RESET_LABEL, + invalidLabel: 'Enter one or more paths, separated by commas.', + disabled, + onEdit: (text) => props.edit('extraRoots', text), + onReset: () => props.resetField('extraRoots'), + }) + + return h( + SettingsForm, + { + labels: { + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + readOnly: 'This deployment stores settings read-only.', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + save: 'Save', + saving: 'Saving\u2026', + }, + state, + onSave: props.save, + onDiscard: props.discard, + // Column rather than a bare array, so the two controls keep the form's + // own spacing instead of sitting flush against each other. + children: h('div', { style: { display: 'flex', flexDirection: 'column', gap: '18px' } }, mode, extraRoots), + }, + ) + } + + /** + * Mount the facet settings card while the Host serves its namespace. + * + * `whileServed` is the gate that makes the `volatile()` markers load-bearing: + * the card is registered only while `facet-policy` appears in the Host's + * served namespaces. An unserved namespace would be a control that silently + * writes nowhere, so it registers nothing at all. + * + * @param {object} ctx - the browser plugin context. + */ + function apply(ctx) { + // `inject` rather than a required dependency, so a deployment that composed + // no settings provider loses the card and keeps everything else this bundle + // contributes. + ctx.inject(['configForms'], (scoped) => { + const card = new FacetCardController(scoped.configForms.get(SETTINGS_ENTRY)) + scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form') + + scoped.effect( + () => + scoped.configForms.whileServed([SETTINGS_ENTRY], () => + scoped.slots.inject('plugins.bundle.config', () => + scoped.slots.register( + { + name: 'plugins.bundle.config', + key: BUNDLE_CONFIG_KEY, + inject: () => card.inject(), + }, + FacetCard, + ), + ), + ), + 'dsh-facet: settings page', + ) + }) + } + + module.exports = { name: 'dsh-facet', inject: ['slots'], apply, SETTINGS_ENTRY, BUNDLE_CONFIG_KEY, FacetCard, FacetCardController } + return module.exports + }, +}) diff --git a/package.json b/package.json index 79e40be..9f49728 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "types": "./lib/types/guard.d.ts", "default": "./lib/guard.js" }, + "./client": { + "default": "./client/client.js" + }, "./cordis.patch.yml": "./cordis.patch.yml", "./presets/facet.patch.yml": "./presets/facet.patch.yml", "./package.json": "./package.json" @@ -21,6 +24,7 @@ "files": [ "index.js", "lib", + "client", "presets", "cordis.patch.yml", "README.md" @@ -40,6 +44,13 @@ "./cordis.patch.yml", "./presets/facet.patch.yml" ] + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ] } }, "keywords": [ diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..0e7cf4d --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,238 @@ +/** + * The browser half: the two keys it binds, and the fields it stages. + * + * This file exists because of a bug that no host-side test could catch. Marking + * the schema's fields `volatile()` makes the Host *serve* the namespace, but the + * Plugins page draws only what a browser half registers — so the plugin showed no + * settings card at all, and every host-side test passed. + * + * The wiring is therefore the thing worth testing, and the two keys are the trap: + * the settings DOCUMENT is keyed by the profile entry id (`facet-policy`) while + * the CARD is keyed by the package name (`dsh-facet`). Every sibling plugin + * happens to name those identically, which is exactly why this drifted. A card + * bound to the wrong id renders, saves, and writes nowhere. + * + * `client/client.js` is a `window.__ModuleLoader__` module rather than an ES + * module — that is how the harness loads a browser half without a bundler — so + * the test stands up the two globals it reads and drives it from there. + */ + +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { test } from 'node:test' + +import { Config } from '../index.js' + +/** Load the browser half with `window` and `require` faked, as the seed would. */ +async function loadClient() { + const source = await readFile(new URL('../client/client.js', import.meta.url), 'utf8') + + /** Every spec a card staged, in construction order. */ + const staged = [] + + const primitives = { + SettingsForm: 'SettingsForm', + SettingsValueField: 'SettingsValueField', + SegmentedControl: 'SegmentedControl', + /** Stands in for the real model: records the specs and answers with the value. */ + SettingsFormModel: class { + constructor(scope, specs) { + this.scope = scope + this.specs = specs + staged.push(...specs) + } + bind(project) { + this.project = project + return { project } + } + shell() { + return { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + } + field(name) { + const spec = this.specs.find((candidate) => candidate.field === name) + return { text: spec?.format(this.scope.value?.[name]) ?? '', overridden: false, invalid: false } + } + actions() { + return { edit: () => {}, resetField: () => {}, save: () => {}, discard: () => {} } + } + dispose() {} + }, + } + + const require = (id) => { + if (id === 'react') return { createElement: (type, props, ...children) => ({ type, props, children }) } + if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives + throw new Error(`client.js required an unexpected module: ${id}`) + } + + let registered + const loader = { load: (value) => (registered = value) } + // eslint-disable-next-line no-new-func -- the file is a ModuleLoader script, not an ES module. + new Function('window', 'require', source)({ __ModuleLoader__: loader }, require) + + assert.notEqual(registered, undefined, 'client.js never called window.__ModuleLoader__.load') + return { module: registered.factory(require), staged, primitives } +} + +/** Drive `apply` against a fake slot/configForms surface and report what it did. */ +function drive(mod, { served = true, value = {} } = {}) { + const seen = { formNs: undefined, slot: undefined, entry: undefined, card: undefined, watched: undefined } + const disposers = [] + + const ctx = { + inject: (names, scope) => { + const scoped = { + configForms: { + get: (ns) => { + seen.formNs = ns + return { value } + }, + whileServed: (namespaces, register) => { + seen.watched = namespaces + // The real `whileServed` registers only while the Host serves the + // namespace; `served` is what the Host's `settings.describe` answers. + if (!served) return () => {} + return register(new Set(namespaces)) + }, + }, + slots: { + inject: (name, register) => { + seen.slot = name + register() + }, + register: (options, component) => { + seen.entry = options + seen.card = component + return () => {} + }, + }, + effect: (fn) => { + const dispose = fn() + if (typeof dispose === 'function') disposers.push(dispose) + return dispose + }, + } + scope(scoped) + }, + } + + mod.apply(ctx) + return { seen, disposers } +} + +test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => { + const { module: mod } = await loadClient() + assert.equal(mod.name, 'dsh-facet') + assert.deepEqual(mod.inject, ['slots']) + assert.equal(typeof mod.apply, 'function') +}) + +test('the form binds to the profile entry id while the card registers under the package name', async () => { + const { module: mod } = await loadClient() + const { seen } = drive(mod) + + // These two are deliberately different strings. `facet-policy` is the `id` of + // this bundle's row in cordis.patch.yml — what `Config` is filed under. The + // card is dispatched by the BUNDLE's package name. + assert.equal(seen.formNs, 'facet-policy') + assert.equal(seen.entry.key, 'dsh-facet') + assert.notEqual(seen.formNs, seen.entry.key, 'if these ever coincide, the trap this test guards has moved') + + assert.equal(seen.slot, 'plugins.bundle.config') + assert.deepEqual(seen.watched, ['facet-policy']) +}) + +test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => { + const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8') + const { module: mod } = await loadClient() + const { seen } = drive(mod) + + // A rename in the patch without a rename here is a card that writes nowhere, + // so the id is read out of the patch rather than trusted to the test. + assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`)) + assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, seen.entry.key) +}) + +test('nothing is registered while the Host does not serve the namespace', async () => { + const { module: mod } = await loadClient() + const { seen } = drive(mod, { served: false }) + assert.equal(seen.formNs, 'facet-policy', 'the form is still bound') + assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves') +}) + +test('the card stages exactly the fields the host marks volatile', async () => { + const { module: mod, staged } = await loadClient() + drive(mod) + + const declared = Object.entries(Config.dict) + .filter(([, schema]) => schema.meta.volatile === true) + .map(([key]) => key) + + // A field served but never staged is invisible in the UI; a field staged but + // never served makes `whileServed` withhold the whole card. + assert.deepEqual( + staged.map((spec) => spec.field).sort(), + declared.sort(), + 'the card and the host schema have drifted apart', + ) +}) + +test('the choice field refuses a value the schema would reject, and clears on empty', async () => { + const { module: mod, staged } = await loadClient() + drive(mod) + const mode = staged.find((spec) => spec.field === 'mode') + + assert.equal(mode.parse('deny').value, 'deny') + assert.equal(mode.parse(' DENY ').value, 'deny') + assert.deepEqual(mode.parse(''), { kind: 'clear' }) + // Undefined marks the draft invalid, which blocks the save rather than + // coercing a typo into a mode the Host would reject anyway. + assert.equal(mode.parse('maybe'), undefined) +}) + +test('the list field clears rather than storing an empty array that shadows the default', async () => { + const { module: mod, staged } = await loadClient() + drive(mod) + const roots = staged.find((spec) => spec.field === 'extraRoots') + + assert.deepEqual(roots.parse('a, b\nc').value, ['a', 'b', 'c']) + assert.deepEqual(roots.parse(' '), { kind: 'clear' }) + assert.equal(roots.format(['a', 'b']), 'a, b') + assert.equal(roots.format(undefined), '') +}) + +test('the card renders both views without reaching for anything it was not given', async () => { + const { module: mod } = await loadClient() + const { seen } = drive(mod) + + assert.equal(typeof seen.card, 'function') + assert.equal(seen.card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.') + + const tree = seen.card({ + view: 'page', + useFacetCard: (select) => + select({ + available: true, + writable: true, + dirty: false, + invalid: false, + saving: false, + failed: false, + mode: { text: 'deny', overridden: false, invalid: false }, + extraRoots: { text: 'src/shared', overridden: true, invalid: false }, + }), + edit: () => {}, + resetField: () => {}, + save: () => {}, + discard: () => {}, + }) + + // The form frame, with the segmented control reading the staged mode. Children + // ride the `children` prop, which is how the platform's own cards pass them. + assert.equal(tree.type, 'SettingsForm') + const controls = tree.props.children.children + assert.equal(controls[0].type, 'div') + assert.equal(controls[1].type, 'SettingsValueField') + assert.equal(controls[0].children[1].props.value, 'deny') + assert.equal(controls[1].props.text, 'src/shared') +})