4 Commits
Author SHA1 Message Date
gitea-actions f36d46efa1 Release v0.1.4 [skip release] [skip actions] 2026-09-26 08:03:19 +00:00
NotExpectedYet a1c8ecb1e8 fix: say so when a session has no approval channel to ask through
Release / release (push) Successful in 6s
`ask` mode promises that an out-of-facet call is put to the user, and that
promise needs a channel. A session can be composed without one: the
`danger-full-access` permission preset sets `approval: never`, and the
approval service's `decide()` then returns "rejected" before consulting any
answerer. The call is refused, `facet action=add` is refused with it — widening
is itself a question — and the scope can never be widened.

The model is told none of that. `serviceAsk` replaces a rejected ask with
`the user rejected tool \"…\"`, discarding the reason this guard wrote, so the
binding is the only channel left that can state what is true. It now reads the
session's effective approval policy and, when there is no channel, says the call
was refused rather than asked and names the mechanism that still works:
`extraRoots` is configuration applied by the profile, not a grant asked for per
conversation.

A missing approval service counts as no channel (the tool pipeline denies an ask
it cannot route); an unreadable policy counts as a channel, so a transient
failure never tells a user to go and edit their configuration.
2026-09-26 09:02:48 +01:00
gitea-actions 6e696f10a2 Release v0.1.3 [skip release] [skip actions] 2026-09-26 06:50:42 +00:00
NotExpectedYet 1be3a67b97 fix: put the settings card on the Plugins page with a browser half
Release / release (push) Successful in 6s
The `volatile()` markers made the Host serve the namespace, and that was as
far as it went: the Plugins page does not build a card from a `Config` schema.
It draws only what browser halves register into the slots it declares, which is
why dsh-slopo, dsh-recall and dsh-context all ship a `client/` — and why this
plugin, with two served fields, still showed nothing.

The card registers into `plugins.bundle.config` while
`configForms.whileServed` reports the namespace served. The two keys are
deliberately different strings and that is the trap in the file: the settings
document is filed under the profile entry id (`facet-policy`) while the card is
keyed by the package name (`dsh-facet`). Every sibling plugin happens to name
them identically. Binding the form to the wrong one yields a card that renders,
saves, and writes nowhere, so `test/client.test.js` pins both, holds the card's
field list to the schema's volatile fields, and refuses to register at all when
the namespace is not served.
2026-09-26 07:39:57 +01:00
9 changed files with 698 additions and 14 deletions

No files matched your search

+14
View File
@@ -2,6 +2,20 @@
Every release, newest first, generated from the commits in each release. Every release, newest first, generated from the commits in each release.
## v0.1.4 — 2026-09-26
### Fixed
- say so when a session has no approval channel to ask through
## v0.1.3 — 2026-09-26
### Fixed
- put the settings card on the Plugins page with a browser half
## v0.1.2 — 2026-09-26 ## v0.1.2 — 2026-09-26
### Fixed ### Fixed
+23 -7
View File
@@ -188,12 +188,13 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified: Verified:
- 45 tests (`npm test`) over the scope algebra, the policy state, and the guard — - 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and
including that containment is decided on canonical targets (a symlink inside the browser half — including that containment is decided on canonical targets
the facet pointing outside it is refused), that a prefix is not a parent (a symlink inside the facet pointing outside it is refused), that a prefix is
(`/a/room` does not contain `/a/roommate`), that a malformed tool call never not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
makes the guard throw, and that every `facet` result satisfies the tool's own call never makes the guard throw, that a live `mode` edit decides the next call
declared output schema. 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 - The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`, ./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 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/policy.js the stateful half — scope resolution, grants
lib/scope.js the pure half — argument extraction, text, reasons lib/scope.js the pure half — argument extraction, text, reasons
lib/guard.js preset row: rule, binding, tool, pre-execute guard lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard` 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 ## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from, - [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+291
View File
@@ -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
},
})
+36 -1
View File
@@ -71,6 +71,35 @@ export function apply(ctx, config) {
// the session was loaded with, for the life of the session. // the session was loaded with, for the life of the session.
const modeNow = () => policy.mode const modeNow = () => policy.mode
/**
* Whether an approval request from this session can reach a person.
*
* `ask` mode is a promise that an out-of-facet call is *put to the user*, and
* that promise needs a channel. A session can be composed without one: the
* `danger-full-access` permission preset sets `approval: never`, under which
* the approval service's `decide()` returns "rejected" before consulting any
* answerer. An ask is then a denial, and `facet action=add` — itself an ask —
* is denied with it, so the scope can never be widened while the model is told
* only `the user rejected tool "…"`. Naming that is the binding's job; see
* `facetContextText`.
*
* A missing service is a definite no (the tool pipeline denies an ask it
* cannot route). An unreadable policy is treated as a yes, so a transient
* failure does not tell a user to go and edit their configuration.
*
* @param {{session?: object}|undefined} agent - the agent whose session supplies the policy.
* @returns {boolean} whether an ask can reach a person.
*/
const canAsk = (agent) => {
const approval = ctx.get('approval')
if (approval === undefined || agent?.session === undefined) return false
try {
return approval.effectivePolicy(agent.session) !== 'never'
} catch {
return true
}
}
// `off` is the one part that stays a mount-time decision. Deciding it here // `off` is the one part that stays a mount-time decision. Deciding it here
// means an `off` deployment contributes no rule, no binding and no tool — // means an `off` deployment contributes no rule, no binding and no tool —
// which is the point of the mode. The cost is that turning it *on* later needs // which is the point of the mode. The cost is that turning it *on* later needs
@@ -187,7 +216,13 @@ export function apply(ctx, config) {
text: (context) => { text: (context) => {
const scope = policy.scopeFor(context?.agent) const scope = policy.scopeFor(context?.agent)
if (scope === undefined) return '' if (scope === undefined) return ''
return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode: modeNow() }) return facetContextText({
cwd: scope.cwd,
root: scope.root,
extras: scope.extras,
mode: modeNow(),
canAsk: canAsk(context?.agent),
})
}, },
}) })
+17 -3
View File
@@ -175,17 +175,31 @@ export function displayPath(cwd, path) {
* @param {string|undefined} input.root - the facet root, or undefined when unscoped. * @param {string|undefined} input.root - the facet root, or undefined when unscoped.
* @param {readonly string[]} input.extras - absolute granted paths, outside the root. * @param {readonly string[]} input.extras - absolute granted paths, outside the root.
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled. * @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
* @param {boolean} [input.canAsk] - whether an approval request can reach the user. False means every ask is refused before anyone is consulted.
* @returns {string} context text, or `''` to contribute nothing. * @returns {string} context text, or `''` to contribute nothing.
*/ */
export function facetContextText(input) { export function facetContextText(input) {
const { cwd, root, extras = [], mode } = input const { cwd, root, extras = [], mode, canAsk = true } = input
if (mode === 'off' || typeof root !== 'string' || root === '') return '' if (mode === 'off' || typeof root !== 'string' || root === '') return ''
// Stated in terms of what the model should *do*, because the same sentence is // Stated in terms of what the model should *do*, because the same sentence is
// what the user reads in an approval prompt. A denial that does not say how to // what the user reads in an approval prompt. A denial that does not say how to
// proceed is a denial the agent works around with `bash`. // proceed is a denial the agent works around with `bash`.
const reach = //
mode === 'deny' // `canAsk` comes first because it collapses the other two. A session can be
// composed without an approval channel — the `danger-full-access` permission
// preset sets `approval: never`, and `decide()` then returns "rejected" before
// any answerer is consulted. There, an out-of-facet call is refused whatever
// `mode` says, and `facet action=add` is refused with it, because widening is
// itself a question. Telling the agent to ask would send it into a loop, and
// the reason this guard writes never reaches it: `serviceAsk` replaces a
// rejected ask with `the user rejected tool "…"`. So the binding is the one
// channel that can say what is true, and it names the mechanism that still
// works — `extraRoots` is configuration applied by the profile, not a grant
// asked for per conversation.
const reach = !canAsk
? 'refused — approval prompts are off in this session, so `facet action=add` cannot be approved either; only the user can widen this, by adding the path to `extraRoots`'
: mode === 'deny'
? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope' ? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again' : 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
+12 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-facet", "name": "dsh-facet",
"version": "0.1.2", "version": "0.1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.", "description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
@@ -14,6 +14,9 @@
"types": "./lib/types/guard.d.ts", "types": "./lib/types/guard.d.ts",
"default": "./lib/guard.js" "default": "./lib/guard.js"
}, },
"./client": {
"default": "./client/client.js"
},
"./cordis.patch.yml": "./cordis.patch.yml", "./cordis.patch.yml": "./cordis.patch.yml",
"./presets/facet.patch.yml": "./presets/facet.patch.yml", "./presets/facet.patch.yml": "./presets/facet.patch.yml",
"./package.json": "./package.json" "./package.json": "./package.json"
@@ -21,6 +24,7 @@
"files": [ "files": [
"index.js", "index.js",
"lib", "lib",
"client",
"presets", "presets",
"cordis.patch.yml", "cordis.patch.yml",
"README.md" "README.md"
@@ -40,6 +44,13 @@
"./cordis.patch.yml", "./cordis.patch.yml",
"./presets/facet.patch.yml" "./presets/facet.patch.yml"
] ]
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager"
]
} }
}, },
"keywords": [ "keywords": [
+238
View File
@@ -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')
})
+45 -2
View File
@@ -58,13 +58,22 @@ const fsLikeLocal = {
}, },
} }
/** Capture what `apply` registered, so the test can drive it. */ /**
function harness({ mode = 'ask', extraRoots = [], tool = true, config } = {}) { * Capture what `apply` registered, so the test can drive it.
*
* `approval` stands in for the approval service named by its policy: `'ask'`
* and `'never'` are the two the schema admits, and leaving it undefined models a
* deployment that composed no approval service at all — a case the tool pipeline
* denies outright, so the guard must treat it as no channel.
*/
function harness({ mode = 'ask', extraRoots = [], tool = true, config, approval } = {}) {
const policy = createFacetPolicy(config ?? { mode, extraRoots }) const policy = createFacetPolicy(config ?? { mode, extraRoots })
const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined } const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined }
const ctx = { const ctx = {
facetPolicy: policy, facetPolicy: policy,
fs: fsLikeLocal, fs: fsLikeLocal,
get: (name) =>
name === 'approval' && approval !== undefined ? { effectivePolicy: () => approval } : undefined,
systemPrompt: { systemPrompt: {
section: (value) => { section: (value) => {
captured.section = value captured.section = value
@@ -275,6 +284,40 @@ test('the binding follows the agent, and is empty without one', () => {
assert.equal(captured.context.text(undefined), '') assert.equal(captured.context.text(undefined), '')
}) })
test('the binding refuses honestly when the session has no approval channel', () => {
// `danger-full-access` sets approval: never, so `decide()` returns "rejected"
// before any answerer is consulted — and `facet action=add`, itself an ask, is
// refused with it. The reason the guard writes never reaches the model on that
// path (`serviceAsk` replaces it with `the user rejected tool "…"`), so the
// binding is the only place the truth can be said.
const { captured } = harness({ mode: 'ask', approval: 'never' })
const text = captured.context.text({ agent: agent() })
assert.match(text, /approval prompts are off in this session/)
assert.match(text, /cannot be approved either/)
assert.match(text, /`extraRoots`/)
// The promise `ask` makes cannot be kept here, so it must not be made.
assert.doesNotMatch(text, /put to the user as a question/)
})
test('an approval channel keeps the promise, and a missing service counts as none', () => {
const withChannel = harness({ mode: 'ask', approval: 'ask' })
assert.match(withChannel.captured.context.text({ agent: agent() }), /put to the user as a question/)
// No approval service at all: the tool pipeline denies an ask it cannot route,
// so the binding must not offer one.
const without = harness({ mode: 'ask' })
assert.match(without.captured.context.text({ agent: agent() }), /approval prompts are off in this session/)
})
test('deny mode still names the way that works when there is no channel', () => {
const { captured } = harness({ mode: 'deny', approval: 'never' })
const text = captured.context.text({ agent: agent() })
assert.match(text, /refused/)
assert.match(text, /`extraRoots`/)
})
test('the facet tool reports the scope', async () => { test('the facet tool reports the scope', async () => {
const { captured } = harness() const { captured } = harness()
const value = await captured.tool.execute({ action: 'status' }, { agent: agent() }) const value = await captured.tool.execute({ action: 'status' }, { agent: agent() })
+22
View File
@@ -94,6 +94,28 @@ test('facetContextText carries the binding that does change', () => {
assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '') assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '')
}) })
test('a session with no approval channel says so instead of promising a question', () => {
// `approval: never` refuses every ask before an answerer is consulted, so the
// mode stops mattering: `ask` and `deny` both end in a refusal, and
// `facet action=add` cannot be approved either. `canAsk: false` is the one
// input that collapses them, and it has to name the mechanism that still
// works — `extraRoots` is configuration, not a per-conversation grant.
for (const mode of ['ask', 'deny']) {
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
assert.match(text, /outside the facet: refused — approval prompts are off in this session/)
assert.match(text, /`facet action=add` cannot be approved either/)
assert.match(text, /`extraRoots`/)
assert.doesNotMatch(text, /put to the user as a question/)
}
// The default stays a promise: a caller that knows nothing about approval
// channels must not silently change what the binding says.
assert.match(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
/put to the user as a question/,
)
})
test('displayPath prefers the shortest unambiguous form', () => { test('displayPath prefers the shortest unambiguous form', () => {
assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts') assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts')
assert.equal(displayPath('/w', '/w'), '.') assert.equal(displayPath('/w', '/w'), '.')