Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36d46efa1 | ||
|
|
a1c8ecb1e8 | ||
|
|
6e696f10a2 | ||
|
|
1be3a67b97 |
No files matched your search
@@ -2,6 +2,20 @@
|
||||
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -71,6 +71,35 @@ export function apply(ctx, config) {
|
||||
// the session was loaded with, for the life of the session.
|
||||
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
|
||||
// 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
|
||||
@@ -187,7 +216,13 @@ export function apply(ctx, config) {
|
||||
text: (context) => {
|
||||
const scope = policy.scopeFor(context?.agent)
|
||||
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
@@ -175,17 +175,31 @@ export function displayPath(cwd, path) {
|
||||
* @param {string|undefined} input.root - the facet root, or undefined when unscoped.
|
||||
* @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 {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.
|
||||
*/
|
||||
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 ''
|
||||
|
||||
// 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
|
||||
// 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'
|
||||
: '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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-facet",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"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.",
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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 captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined }
|
||||
const ctx = {
|
||||
facetPolicy: policy,
|
||||
fs: fsLikeLocal,
|
||||
get: (name) =>
|
||||
name === 'approval' && approval !== undefined ? { effectivePolicy: () => approval } : undefined,
|
||||
systemPrompt: {
|
||||
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), '')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const { captured } = harness()
|
||||
const value = await captured.tool.execute({ action: 'status' }, { agent: agent() })
|
||||
|
||||
@@ -94,6 +94,28 @@ test('facetContextText carries the binding that does change', () => {
|
||||
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', () => {
|
||||
assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts')
|
||||
assert.equal(displayPath('/w', '/w'), '.')
|
||||
|
||||
Reference in new issue
Block a user