5 Commits
Author SHA1 Message Date
gitea-actions ef8829cb56 Release v0.2.0 [skip release] [skip actions] 2026-09-27 05:18:46 +00:00
NotExpectedYet 266e90aa2d feat: set a conversation's scope from the composer, where the agent cannot reach
Release / release (push) Successful in 6s
The per-session scope the host half now reads had no writer. This is it: a
crosshair on the composer tool row, present only in sessions running the facet
preset, opening a panel that sets the focus and the paths allowed outside it.

The gate is the session's own `agentPreset` projection rather than anything
inferred from the URL or from the plugin being installed, so a control for a
plugin that is not running never appears.

It writes through the settings document — the same one the plugin card edits —
because `settings.mutate` accepts a path under a volatile node and refuses
anything else. That is what makes the split real rather than aspirational: the
agent has no write path to `scopes`, and the panel's write is the only one that
can widen a session.

The panel also carries the mode details and the usage notes, which the preset
picker's guide dialog cannot show for a preset no core package ships — its
guide table holds four shipped ids and is looked up only for built-ins.

Also adds `@deepseek-ai/dsh-client-ui-conversation` to the client half's inject
list: registering into `conversation.input.left` is a dependency on the package
that declares it, and `slots.inject` waits for a slot rather than creating one,
so naming another package's slot without loading it first registers nothing —
silently, because nothing errors.
2026-09-27 06:15:53 +01:00
NotExpectedYet d9caf4eeec feat: scope a session from a declaration the agent cannot widen
The facet root was the session's working directory and nothing else, so the
only way to scope a conversation to a module was to make the module a
workspace — and the only way to reach anything outside it was `extraRoots`,
which is a fact about the deployment rather than about one conversation.

Scope now has two layers, and the split is the point. The **user layer** is
`config.scopes[sessionId]` — a `focus` and a list of `extras`, written only
from the browser context panel through the settings service, which is what
`settings.mutate` requiring a volatile path buys us. The **agent layer** is in
memory: a narrowing `focus` and its grants. Both can only ever reduce what the
user layer allows, so an agent that goes wrong cannot widen its own scope.

`facet action=focus` is therefore the one scope change the agent may make
unasked, bounded by the scope already in force and decided on canonical targets.
`reset` clears the agent's side only, landing on the user's declaration rather
than on the session directory — landing on the directory would have made
`reset` a widening. `extraRoots` demotes to an optional deployment-wide seed.

The binding now names the context panel as the way to widen, because it is: in
a deployment with no approval channel, `facet action=add` cannot be granted at
all.
2026-09-27 06:10:53 +01:00
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
12 changed files with 1143 additions and 83 deletions

No files matched your search

+15
View File
@@ -2,6 +2,21 @@
Every release, newest first, generated from the commits in each release.
## v0.2.0 — 2026-09-27
### Added
- set a conversation's scope from the composer, where the agent cannot reach
- scope a session from a declaration the agent cannot widen
## 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
+54 -19
View File
@@ -103,23 +103,22 @@ first turn has run, so choose it when you start the conversation.
### Configure it in your profile, not in the package
The host row's config is where this deployment's facts belong — the profile's own
`cordis.patch.yml`:
The host row's config is where this *deployment's* facts belong — the profile's
own `cordis.patch.yml`:
```yaml
- id: facet-policy
name: dsh-facet
config:
mode: ask # ask | deny | off
extraRoots: # always in scope, in every session
- ../../shared # relative to each session's own directory
extraRoots: [] # optional: a seed of paths always in scope, every session
```
Both fields are also on the plugin's card under **Plugins** in the Web UI, which
is the same layer reached a different way: a settings write rewrites this entry in
the profile patch rather than storing anything new. An edit there is live — the
guard reads `mode` and `extraRoots` on every call, so a change decides the next
file call without a restart.
Both are also on the plugin's card under **Plugins** in the Web UI, which is the
same layer reached a different way: a settings write rewrites this entry in the
profile patch rather than storing anything new. An edit there is live — the guard
reads `mode` and `extraRoots` on every call, so a change decides the next file
call without a restart.
The one exception is `off`. Choosing it while a session is running stops the guard
immediately and empties the binding on the next request, because the binding is a
@@ -132,11 +131,37 @@ patch. `ask` and `deny` swap freely in both directions.
place the rule and the binding among the other prompt sections, which is decided
once at load, and no section is re-ordered without a remount.
`extraRoots` is for code a module legitimately always depends on. Anything you
grant by hand is per-conversation and is never persisted — "this session may
also see `src/shared`" is a statement about one conversation, and writing it down
would quietly widen every future one, which is the failure this plugin exists to
prevent.
### Per-session scope, and who gets to decide it
Scope has two layers, and the split is the point:
| layer | what it is | who can write it |
|---|---|---|
| **the user's declaration** | `scopes[<session id>]` in this row's config — a `focus` and a list of `extras` | only the context panel, through the settings service |
| **the agent's** | a narrowing `focus` and any session grants, held in memory | the agent, and only ever to *reduce* |
The effective root is the agent's narrowing while it still sits inside your root,
and your root otherwise. So an agent that goes wrong cannot widen its own scope,
and `facet action=reset` returns it to what you declared rather than to the
session directory — landing on the directory would have made `reset` a widening.
You set the declaration from the **crosshair button on the composer tool row**,
which appears only in sessions running the `facet` preset. It shows the focus and
the allowed paths, adds and removes them, and carries the mode details and usage
notes that the preset picker's guide dialog cannot show for a preset no core
package ships. Writes go to the profile patch, so reopening a chat gives back the
scope it had.
Because the panel owns widening, `facet action=add` is the *other* path and the
narrower one: it is gated on an approval channel, and a deployment with none — the
`danger-full-access` permission preset sets `approval: never` — refuses it before
anyone is asked. In that deployment the panel is the only way to widen, which is
exactly what the binding tells the agent.
`extraRoots` is only a seed now. Before per-session scope it was the *only* way to
widen anything, which meant "this conversation needs the entity folder" had to be
written as a fact about every conversation in every workspace. It is for paths a
deployment genuinely always wants, and it is empty by default.
## The compaction row
@@ -188,13 +213,15 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified:
- 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and
- 73 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.
without the guard being remounted, that an agent narrowing the user has since
narrowed past is dropped rather than honoured, that `reset` lands on the
declared scope rather than the session directory, 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`,
@@ -264,13 +291,13 @@ it.
```
index.js host row: the policy, published as ctx.facetPolicy
client/client.js browser half: the settings card on the Plugins page
client/client.js browser half: the settings card, and the context panel
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/ 53 tests
test/ 73 tests
```
`client/client.js` is a browser half rather than a module the host imports, and
@@ -287,6 +314,14 @@ entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
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.
The same file carries the context panel, registered into
`conversation.input.left` — the composer tool row — and gated on the session's own
`agentPreset` projection, so it appears only where the facet is actually in force.
That gate is why `scopes` is `volatile()` in the host schema: `settings.mutate`
refuses a path that is not under a volatile node, and refusing is exactly right —
the panel is the only writer, and a plugin that could not serve the namespace
would be a control that silently stored nothing.
## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+360 -3
View File
@@ -39,7 +39,7 @@ window.__ModuleLoader__.load({
const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const { SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
const { Modal, SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
const h = react.createElement
@@ -248,6 +248,328 @@ window.__ModuleLoader__.load({
)
}
/**
* The preset id whose sessions get a context button.
*
* A session records its preset as a projection value, so the button can
* appear exactly where the facet is in force and stay out of the way
* everywhere else. Rendering it unconditionally would put a control for a
* plugin that is not running on every conversation in the deployment.
*/
const FACET_PRESET = 'facet'
/** The composer tool row's entry id, and the button's accessible name. */
const CONTEXT_ENTRY_ID = 'facet-context'
const CONTEXT_LABEL = 'Facet context'
/** The scope one session has declared, normalised, or an empty declaration. */
function declaredFor(snapshot, sessionId) {
const all = snapshot?.value?.scopes
const mine = all !== null && typeof all === 'object' ? all[sessionId] : undefined
const entry = mine !== null && typeof mine === 'object' ? mine : {}
return {
focus: typeof entry.focus === 'string' ? entry.focus : '',
extras: Array.isArray(entry.extras) ? entry.extras.filter((path) => typeof path === 'string') : [],
}
}
/**
* Write one session's whole declaration.
*
* One path, one write: the panel always supplies the complete declaration it
* wants, so a queued edit can never half-apply and leave a session scoped to
* something nobody chose. An empty declaration is removed rather than stored,
* so a session the user has finished with leaves no residue in the profile
* patch — and so clearing both fields means "the session directory", which is
* the schema's own default rather than a stored empty override.
*
* @returns {Promise<boolean>} whether the Host accepted the write.
*/
function writeDeclaration(form, sessionId, next) {
const focus = typeof next.focus === 'string' ? next.focus.trim() : ''
const extras = (next.extras ?? []).map((path) => String(path).trim()).filter((path) => path !== '')
const value = {
...(focus === '' ? {} : { focus }),
...(extras.length === 0 ? {} : { extras }),
}
const op =
Object.keys(value).length === 0
? { op: 'unset', path: ['scopes', sessionId] }
: { op: 'set', path: ['scopes', sessionId], value }
return Promise.resolve(form.mutate([op]))
}
/** Shared control styling, so the panel reads as part of the product. */
const controlStyle = {
boxSizing: 'border-box',
height: '32px',
padding: '0 10px',
fontSize: '13px',
fontFamily: 'inherit',
color: 'var(--dsw-alias-label-primary)',
background: 'var(--dsw-alias-bg-layer-3)',
border: '0.5px solid var(--dsw-alias-border-l4)',
borderRadius: 'var(--dsw-radius-md)',
}
const quietButtonStyle = {
...controlStyle,
cursor: 'pointer',
color: 'var(--dsw-alias-label-secondary)',
background: 'transparent',
}
const sectionTitleStyle = {
margin: '0 0 6px',
fontSize: '13px',
fontWeight: 600,
color: 'var(--dsw-alias-label-primary)',
}
const bodyStyle = { margin: '0 0 14px', fontSize: '12.5px', lineHeight: '19px', color: 'var(--dsw-alias-label-secondary)' }
const listedStyle = { ...bodyStyle, margin: '4px 0 0', fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)' }
/** The crosshair that marks the button: a scope, not a setting. */
const CONTEXT_GLYPH = h(
'svg',
{ width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
h('circle', { cx: 8, cy: 8, r: 3.2, stroke: 'currentColor', strokeWidth: 1.3 }),
h('path', {
d: 'M8 1.2v2.6M8 12.2v2.6M1.2 8h2.6M12.2 8h2.6',
stroke: 'currentColor',
strokeWidth: 1.3,
strokeLinecap: 'round',
}),
)
/**
* The facet context panel: what this conversation may see, and who decides.
*
* This is the only surface that writes per-session scope. The agent has no
* write path to it — not a narrow one, none — which is what makes "the user
* decides what is in scope" a property of the system rather than of the
* agent's instructions.
*
* @param {object} props - the bound form, the session, and the close command.
*/
function FacetContextPanel(props) {
const { form, sessionId, mode, onClose } = props
// `form` is the settings document for `facet-policy`, already a store: the
// panel renders whatever the Host last accepted, including a write from
// another tab.
const snapshot = react.useSyncExternalStore(
(listener) => form.subscribe(listener),
() => form.getSnapshot(),
)
const declared = declaredFor(snapshot, sessionId)
const [focusDraft, setFocusDraft] = react.useState(declared.focus)
const [extraDraft, setExtraDraft] = react.useState('')
const [failure, setFailure] = react.useState('')
const disabled = snapshot.writable !== true
const write = (next) => {
writeDeclaration(form, sessionId, next).then(
(ok) => setFailure(ok ? '' : 'The deployment did not accept that change.'),
() => setFailure('The deployment did not accept that change.'),
)
}
const rows = declared.extras.map((path) =>
h(
'li',
{ key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } },
h('code', { style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' } }, path),
h(
'button',
{
type: 'button',
style: { ...quietButtonStyle, height: '26px' },
disabled,
onClick: () => write({ ...declared, extras: declared.extras.filter((other) => other !== path) }),
},
'Remove',
),
),
)
return h(
Modal,
{ open: true, onClose, title: CONTEXT_LABEL, closeLabel: 'Close', description: `Session ${sessionId}` },
h(
'div',
null,
h('h3', { style: sectionTitleStyle }, 'What this conversation may see'),
h(
'p',
{ style: bodyStyle },
'The agent works inside the focus folder. Anything it needs beyond that is listed below, and the agent can narrow the focus on its own but can never widen it — only you can, here.',
),
h('label', { style: sectionTitleStyle, htmlFor: 'facet-focus' }, 'Focus'),
h(
'div',
{ style: { display: 'flex', gap: '8px' } },
h('input', {
id: 'facet-focus',
type: 'text',
style: { ...controlStyle, flex: 1 },
placeholder: 'the folder the session started in',
value: focusDraft,
disabled,
'data-modal-autofocus': true,
onChange: (event) => setFocusDraft(event.target.value),
onKeyDown: (event) => {
if (event.key !== 'Enter') return
event.preventDefault()
write({ ...declared, focus: focusDraft })
},
}),
h(
'button',
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: focusDraft }) },
'Set',
),
h(
'button',
{
type: 'button',
style: quietButtonStyle,
disabled,
onClick: () => {
setFocusDraft('')
write({ ...declared, focus: '' })
},
},
'Clear',
),
),
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute. Empty means the session folder itself.'),
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
declared.extras.length === 0
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.')
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
h(
'div',
{ style: { display: 'flex', gap: '8px', marginTop: '10px' } },
h('input', {
id: 'facet-extra',
type: 'text',
style: { ...controlStyle, flex: 1 },
placeholder: 'src/shared, ../../CONTROLLER.md',
value: extraDraft,
disabled,
onChange: (event) => setExtraDraft(event.target.value),
onKeyDown: (event) => {
if (event.key !== 'Enter') return
event.preventDefault()
if (extraDraft.trim() === '') return
write({ ...declared, extras: [...declared.extras, extraDraft] })
setExtraDraft('')
},
}),
h(
'button',
{
type: 'button',
style: quietButtonStyle,
disabled,
onClick: () => {
if (extraDraft.trim() === '') return
write({ ...declared, extras: [...declared.extras, extraDraft] })
setExtraDraft('')
},
},
'Add',
),
),
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute — anything the session needs beyond the focus.'),
failure === '' ? null : h('p', { style: { ...bodyStyle, color: 'var(--dsw-alias-state-error-primary)' } }, failure),
h(
'p',
{ style: { ...bodyStyle, marginTop: '18px' } },
`Changes take effect on the next tool call. Deployment mode: ${mode ?? 'ask'}.`,
),
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Mode details'),
h(
'p',
{ style: bodyStyle },
'The mode is set on the plugin card under Plugins, and applies to every session: ' +
'"ask" puts a call outside the facet to you as a question; "deny" refuses it outright; ' +
'"off" disables enforcement while leaving the preset mounted. Where the deployment has no approval ' +
'channel — as here, under the danger-full-access permission preset — "ask" cannot actually reach you, ' +
'so an out-of-facet call is refused and this panel is the way to widen the scope.',
),
h('h3', { style: sectionTitleStyle }, 'How to use'),
h(
'ol',
{ style: { ...bodyStyle, paddingLeft: '18px' } },
h('li', { style: { marginBottom: '6px' } }, 'Start the chat in the repository you want to work in.'),
h('li', { style: { marginBottom: '6px' } }, 'Set Focus to the one folder this conversation is about.'),
h(
'li',
{ style: { marginBottom: '6px' } },
'Add what it needs beyond that — shared types, project docs, a folder it will create a sibling in.',
),
h('li', null, 'Let the agent narrow as the work settles; it will ask you before it needs anything more.'),
),
),
)
}
/**
* The composer tool row's button, and the panel it opens.
*
* Present only for a session running the facet preset, which is what "shows
* up when in facet mode" means mechanically: the session's own projection
* says which preset it runs, so nothing has to be inferred from the URL or
* from the plugin being installed.
*
* @param {object} props - slot props: the session, `useSessions`, and the form.
*/
function FacetContextButton(props) {
const { sessionId, useSessions, form } = props
// Called before the early return, so the hook order is stable across the
// sessions that do and do not run the facet.
const preset = useSessions((state) => state.byId[sessionId]?.projectionValues?.agentPreset)
const [open, setOpen] = react.useState(false)
if (preset !== FACET_PRESET) return null
return h(
react.Fragment,
null,
h(
'button',
{
type: 'button',
'aria-label': CONTEXT_LABEL,
'aria-expanded': open,
title: `${CONTEXT_LABEL} — what this conversation may see`,
onClick: () => setOpen((value) => !value),
style: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
padding: 0,
cursor: 'pointer',
color: open ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
background: open ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent',
border: 0,
borderRadius: 'var(--dsw-radius-sm)',
},
},
CONTEXT_GLYPH,
),
// Mounted only while open, so each visit starts from what the Host holds
// rather than from a draft left over from the last one.
open ? h(FacetContextPanel, { ...props, onClose: () => setOpen(false) }) : null,
)
}
/**
* Mount the facet settings card while the Host serves its namespace.
*
@@ -263,7 +585,8 @@ window.__ModuleLoader__.load({
// 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))
const form = scoped.configForms.get(SETTINGS_ENTRY)
const card = new FacetCardController(form)
scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form')
scoped.effect(
@@ -282,10 +605,44 @@ window.__ModuleLoader__.load({
),
'dsh-facet: settings page',
)
// The context button rides the same form, so the panel and the plugin
// card can never disagree about what the document holds.
scoped.effect(
() =>
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
scoped.slots.inject('conversation.input.left', () =>
scoped.slots.register(
{
name: 'conversation.input.left',
id: CONTEXT_ENTRY_ID,
order: 20,
inject: () => ({ form }),
},
FacetContextButton,
),
),
),
'dsh-facet: context button',
)
})
}
module.exports = { name: 'dsh-facet', inject: ['slots'], apply, SETTINGS_ENTRY, BUNDLE_CONFIG_KEY, FacetCard, FacetCardController }
module.exports = {
name: 'dsh-facet',
inject: ['slots'],
apply,
SETTINGS_ENTRY,
BUNDLE_CONFIG_KEY,
FACET_PRESET,
CONTEXT_ENTRY_ID,
FacetCard,
FacetCardController,
FacetContextButton,
FacetContextPanel,
declaredFor,
writeDeclaration,
}
return module.exports
},
})
+51 -12
View File
@@ -33,20 +33,25 @@ export const name = 'dsh-facet'
/**
* Every configuration field this plugin exposes.
*
* `mode` and `extraRoots` are marked `volatile()`, and that marker is doing two
* jobs rather than one. It is what puts them on the settings page at all — the
* framework's form projection keeps only fields under a volatile ancestor, and
* drops an entry with no such field from `settings.describe` entirely, so
* without it this row has no card. It is also what makes an edit *live*: a
* volatile field is handed to {@link apply} as a reference rather than a value,
* and the framework commits an edit into that reference without re-applying the
* plugin.
* `mode`, `extraRoots` and `scopes` are marked `volatile()`, and that marker is
* doing two jobs rather than one. It is what puts them on the settings page at
* all — the framework's form projection keeps only fields under a volatile
* ancestor, and drops an entry with no such field from `settings.describe`
* entirely, so without it this row has no card. It is also what makes an edit
* *live*: a volatile field is handed to {@link apply} as a reference rather than
* a value, and the framework commits an edit into that reference without
* re-applying the plugin.
*
* The second job is why every reader of these two fields goes through
* The second job is why every reader of these fields goes through
* `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin
* that takes the reference for a string gets a settings card that appears to
* work and changes nothing.
*
* `scopes` is volatile for a third reason: it is the only field the browser
* context panel writes, and `settings.mutate` refuses a path that is not under a
* volatile node. That is the whole mechanism by which the panel — and not the
* agent — decides what one conversation may see.
*
* `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place
* the rule and the binding among the other prompt sections, which is settled
* once at load; making them volatile would put two knobs nobody should touch on
@@ -61,12 +66,46 @@ export const Config = Schema.object({
mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(),
/**
* Paths always in scope, for code a module legitimately depends on — shared
* types, a common test harness. Relative paths resolve against each session's
* own directory, so the same list is meaningful in every workspace.
* A deployment-wide seed of paths always in scope, for code a module
* legitimately depends on — shared types, a common test harness.
*
* Deliberately only a seed. Before per-session scope existed this was the only
* way to widen anything, which meant "this one conversation needs the entity
* folder" had to be written as a fact about every conversation in every
* workspace. Per-session scope belongs in {@link Config.scopes}; this is for
* the paths a deployment genuinely always wants, and it is empty by default.
*
* Relative paths resolve against each session's own directory, so the same
* list is meaningful in every workspace.
*/
extraRoots: Schema.array(String).default([]).volatile(),
/**
* Per-session scope — the user's declaration for one conversation, keyed by
* session id.
*
* Written only by the browser context panel, through the settings service, and
* never by the agent: this plugin exposes no write path to it, and a facet
* session carries no settings tool. `focus` narrows the session to a folder;
* `extras` are paths the conversation may read outside it. Both are relative
* to the session's own directory unless absolute, so a declaration keeps its
* meaning wherever the workspace lives.
*
* Entries outlive their sessions, which is deliberate — reopening a chat
* should give back the scope it had — and costs a little growth in the
* profile patch that the panel can tidy.
*/
scopes: Schema.dict(
Schema.object({
/** The session's facet root, relative to the session directory or absolute. Empty means the session directory itself. */
focus: Schema.string(),
/** Paths this session may also read, outside the root. */
extras: Schema.array(String).default([]),
}),
)
.default({})
.volatile(),
/** Placement of the invariant rule in the system prompt. */
sectionOrder: Schema.number().default(100),
+99 -12
View File
@@ -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),
})
},
})
@@ -211,13 +246,18 @@ function requestedPaths(args) {
}
/**
* The `facet` tool: read the scope, or ask to widen it.
* The `facet` tool: read the scope, narrow it, or ask to widen it.
*
* `add` does not enforce anything itself — the pre-execute listener has already
* put the question to the user, and the body only runs if they said yes. That
* split is deliberate: the approval is a property of the *call*, so it holds
* even if this tool is later replaced or dispatched from `run_code`.
*
* `focus` is the one action this tool performs without asking, and it is the
* only one that can only ever *reduce* the scope. That asymmetry is the whole
* design: an agent may decide a conversation is about one folder, and may not
* decide it is about more.
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
* @param {object} policy - the `facetPolicy` service.
*/
@@ -225,21 +265,23 @@ function facetTool(ctx, policy) {
return defineTool({
name: FACET_TOOL,
description:
'Read this session\u2019s facet scope, or ask the user to grant an extra path. A facet is the folder the ' +
'conversation was started in; the session is scoped to it. Use `status` to see the current scope. Use `add` ' +
'only when you actually need a file outside the facet — it asks the user for permission, and the grant lasts ' +
'for the rest of the conversation. Never call `add` for a path you merely expect to need.',
'Read this session\u2019s facet scope, narrow it to one folder, or ask the user for an extra path. A facet is ' +
'the folder the conversation is scoped to; `status` reports it. Use `focus` when the work has settled on one ' +
'folder inside the facet — narrowing is always allowed and takes effect at once. Use `add` only when you ' +
'actually need a file outside the facet: it asks the user for permission, and the grant lasts for the rest of ' +
'the conversation. Never call `add` for a path you merely expect to need. Where the deployment has no approval ' +
'channel, `add` cannot be granted at all and the user widens the scope from the facet panel in their browser.',
parameters: {
action: {
type: 'string',
required: true,
description: 'One of: status, add, remove, reset.',
description: 'One of: status, focus, add, remove, reset.',
},
paths: {
type: 'array',
items: { type: 'string' },
description:
'Paths to grant, relative to the session directory or absolute. Required for add and remove; ignored otherwise.',
'Paths, relative to the session directory or absolute. Required for add and remove; for focus, exactly one folder inside the current facet.',
},
},
output: {
@@ -272,13 +314,16 @@ function facetTool(ctx, policy) {
const problem =
(action === 'add' || action === 'remove') && requested.length === 0
? `facet: action "${action}" needs \`paths\``
: !['status', 'add', 'remove', 'reset'].includes(action)
? `facet: unknown action "${action}"; expected status, add, remove or reset`
: undefined
: action === 'focus' && requested.length !== 1
? 'facet: action "focus" needs exactly one folder'
: !['status', 'focus', 'add', 'remove', 'reset'].includes(action)
? `facet: unknown action "${action}"; expected status, focus, add, remove or reset`
: undefined
if (problem !== undefined) throw new Error(problem)
const before = policy.grantsFor(sessionId)
if (action === 'add') policy.grant(sessionId, requested, cwd)
if (action === 'focus') policy.narrowTo(sessionId, await narrowing(ctx, policy, exec, requested[0]))
else if (action === 'add') policy.grant(sessionId, requested, cwd)
else if (action === 'remove') policy.revoke(sessionId, requested, cwd)
else if (action === 'reset') policy.reset(sessionId)
@@ -305,6 +350,48 @@ function facetTool(ctx, policy) {
})
}
/**
* Resolve one `focus` request to the canonical root it asks for.
*
* Refuses anything the scope already in force does not contain, because that
* would be a widening by another name — and widening is the user's decision, not
* the agent's. The containment test is the filesystem provider's, on canonical
* targets, for the same reason the guard uses it: a symlink inside the facet
* that points outside it must not become a way through.
*
* The canonical key is what gets recorded, so a later `scopeFor` compares like
* with like. Where the user declared a root through a symlink, the two spellings
* may not share a prefix; the narrowing is then dropped rather than trusted,
* which loses the narrowing and never the boundary.
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context, holding `fs`.
* @param {object} policy - the `facetPolicy` service.
* @param {import('@deepseek-ai/dsh-tools').ToolExecution} exec - the running call.
* @param {string} raw - the folder the model named.
* @returns {Promise<string>} the absolute root to narrow to.
*/
async function narrowing(ctx, policy, exec, raw) {
const scope = policy.scopeFor(exec.agent)
if (scope === undefined) throw new Error('facet: this session has no working directory, so there is nothing to narrow')
let target
let boundary
try {
target = await ctx.fs.resolve(raw, { cwd: scope.cwd, signal: exec.signal })
boundary = await ctx.fs.resolve(scope.root, { cwd: scope.cwd, signal: exec.signal })
} catch {
throw new Error(`facet: cannot resolve "${raw}", so the facet cannot be narrowed to it`)
}
if (target.targetKey !== boundary.targetKey && !ctx.fs.contains(boundary, target)) {
throw new Error(
`facet: "${raw}" is outside the current facet (${scope.root}). Narrowing cannot leave the scope already in force; ` +
'ask the user to widen it from the facet panel if the work really has moved.',
)
}
return target.targetKey
}
/** Model-facing text for one `facet` result. Pure. */
function renderFacet(value) {
const lines = [
+142 -12
View File
@@ -16,7 +16,7 @@
* exists to prevent.
*/
import { isAbsolute, resolve } from 'node:path'
import { isAbsolute, resolve, sep } from 'node:path'
/**
* The symbol cosmokit uses to mark a volatile config reference.
@@ -63,18 +63,78 @@ export function configValue(value) {
* `config` is the *resolved* composition config, so its volatile fields are
* references rather than values; every read below goes through
* {@link configValue} and happens per use. Holding the config and reading it lazily
* is what makes the settings card honest: `mode` and `extraRoots` are edited live
* in the Web UI, and a policy that snapshotted them here would keep answering
* with the values it was loaded with.
* is what makes the settings card honest: `mode`, `extraRoots` and `scopes` are
* edited live in the Web UI, and a policy that snapshotted them here would keep
* answering with the values it was loaded with.
*
* ## Two layers of scope, and only one of them the agent can move
*
* The session's scope is declared in two places on purpose, because the two
* audiences have opposite authority:
*
* * the **user layer** is `config.scopes[sessionId]` — a `focus` and a list of
* `extras`, written only from the browser context panel through the settings
* service. The agent has no write path to it: nothing in this plugin exposes
* one, and a facet session carries no settings tool.
* * the **agent layer** is in memory — a narrowing `focus` override and the
* grants it asked for. Both can only ever *reduce* what the user layer
* allows, so an agent that goes wrong cannot widen its own scope.
*
* The effective root is the agent's narrowing while it still sits inside the
* user's root, and the user's root otherwise. That ordering matters when the
* user narrows after the agent has: the stale agent focus is dropped rather than
* honoured, because honouring it would put the session outside the scope the
* user had just declared.
*
* @param {object} config
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
* @param {readonly string[]} [config.extraRoots] - a deployment-wide seed of standing grants.
* @param {Record<string, {focus?: string, extras?: readonly string[]}>} [config.scopes] - per-session scope, keyed by session id.
* @returns {object} the `facetPolicy` service implementation.
*/
export function createFacetPolicy(config) {
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
const grants = new Map()
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
const narrowed = new Map()
/**
* What the user declared for one session, read fresh.
*
* @param {string|undefined} sessionId - the session to read.
* @returns {{focus?: string, extras?: readonly string[]}|undefined} the declaration.
*/
function declaredScope(sessionId) {
if (sessionId === undefined) return undefined
const all = configValue(config.scopes)
if (all === null || typeof all !== 'object') return undefined
const entry = all[sessionId]
return entry !== null && typeof entry === 'object' ? entry : undefined
}
/**
* The root in force for one session.
*
* The user's `focus` is resolved against the session directory, not against
* the process, so a relative declaration keeps its meaning if the workspace
* moves. The agent's narrowing is applied only while it is still inside that
* root; a plain prefix test is enough here because the guard's own containment
* check, which resolves symlinks, is what authorises a narrowing in the first
* place — this one only has to notice a root that has since moved out from
* under it.
*
* @param {string|undefined} sessionId - the session.
* @param {string} sessionCwd - its resolved working directory.
* @returns {string} the absolute facet root.
*/
function rootFor(sessionId, sessionCwd) {
const declared = declaredScope(sessionId)?.focus
const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
if (agentFocus === undefined) return base
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
return base
}
/**
* The deployment's standing grants, read fresh on every call.
@@ -97,31 +157,77 @@ export function createFacetPolicy(config) {
* something arbitrary: `undefined` here means the guard has no opinion, which
* is the only safe reading of "we do not know where this agent is".
*
* `cwd` stays the session directory even when the root is narrower, because
* it is what relative paths — a grant, a `focus`, an `extraRoots` entry —
* resolve against. A declaration written relative to the session keeps the
* same meaning whatever the session is focused on.
*
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
* @returns {FacetScope|undefined} the scope.
*/
function scopeFor(agent) {
const cwd = agent?.session?.header?.cwd
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
const root = resolve(cwd)
return { cwd: root, root, extras: extrasFor(agent, root) }
const sessionCwd = resolve(cwd)
const sessionId = idOf(agent)
return {
cwd: sessionCwd,
root: rootFor(sessionId, sessionCwd),
extras: extrasFor(agent, sessionCwd),
}
}
/**
* Every path in scope beyond the root: standing grants first, then this
* session's, deduplicated so the prompt does not repeat itself.
* Every path in scope beyond the root: the deployment seed, then what the user
* declared for this session, then what the agent was granted — deduplicated so
* the prompt does not repeat itself.
*
* All three are kept rather than layered, because they answer different
* questions. The seed is "this deployment always needs it", the declaration is
* "this conversation needs it", and a grant is "the user allowed this call".
* Removing one from the list when a narrower one exists would mean a user
* editing their panel could silently drop a path the deployment depends on.
*
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
* @param {string} cwd - the resolved session directory.
* @returns {string[]} absolute paths.
*/
function extrasFor(agent, cwd) {
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
const sessionId = idOf(agent)
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
const declared = declaredScope(sessionId)?.extras
const listed = Array.isArray(declared) ? declared.filter(isPath) : []
const all = [...standingRoots(), ...listed, ...own].map((path) =>
isAbsolute(path) ? resolve(path) : resolve(cwd, path),
)
return [...new Set(all)]
}
/**
* Narrow one session's facet to a path the caller has already authorised.
*
* The containment check is the caller's, and deliberately so: only the guard
* holds `ctx.fs`, and deciding containment on canonical targets is the whole
* reason a symlink inside the facet does not open it. This records the answer;
* {@link rootFor} re-checks that the user has not since narrowed past it.
*
* @param {string} sessionId - the session being narrowed.
* @param {string} absolute - the absolute root to narrow to.
* @returns {string} the recorded focus.
*/
function narrowTo(sessionId, absolute) {
narrowed.set(sessionId, absolute)
return absolute
}
/**
* @param {string} sessionId - the session to read.
* @returns {string|undefined} the agent's narrowing for it, if any.
*/
function narrowFor(sessionId) {
return narrowed.get(sessionId)
}
/**
* @param {string} sessionId - the session being widened.
* @param {readonly string[]} paths - raw paths, resolved against `cwd`.
@@ -154,11 +260,19 @@ export function createFacetPolicy(config) {
}
/**
* Clear one session's agent-side scope: its grants and any narrowing it asked
* for.
*
* Deliberately not a widening. The user's declaration is not touched, so this
* returns the session to what the user set up in the panel — which may be
* narrower than where the agent had got to, and can never be wider.
*
* @param {string} sessionId - the session to clear.
* @returns {string[]} always empty.
*/
function reset(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
return []
}
@@ -173,6 +287,7 @@ export function createFacetPolicy(config) {
/** Drop a finished session's grants. Called on `session/disposed`. */
function forget(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
}
return {
@@ -183,11 +298,16 @@ export function createFacetPolicy(config) {
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
sectionOrder: configValue(config.sectionOrder) ?? 100,
contextOrder: configValue(config.contextOrder) ?? 105,
/** The standing grants. A getter for the same reason as {@link mode}. */
/** The deployment-wide seed of standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
/** What the user declared for one session: their `focus` and `extras`. */
declaredFor: declaredScope,
scopeFor,
rootFor,
narrowTo,
narrowFor,
grant,
revoke,
reset,
@@ -204,3 +324,13 @@ export function createFacetPolicy(config) {
function isPath(value) {
return typeof value === 'string' && value.trim() !== ''
}
/**
* The session id of a calling agent, when it has one.
*
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
* @returns {string|undefined} the id.
*/
function idOf(agent) {
return typeof agent?.session?.id === 'string' ? agent.session.id : undefined
}
+18 -4
View File
@@ -175,18 +175,32 @@ 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'
? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
//
// `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: the facet's context panel, which the user owns and the agent has no
// write path to.
const reach = !canAsk
? 'refused — this session has no approval channel, so `facet action=add` cannot be granted; ask the user to widen the facet from its context panel'
: mode === 'deny'
? 'refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted'
: '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'
// The root is stated absolutely even though it is the session's own directory
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-facet",
"version": "0.1.3",
"version": "0.2.0",
"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.",
@@ -49,7 +49,8 @@
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager"
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-conversation"
]
}
},
+183 -16
View File
@@ -31,6 +31,7 @@ async function loadClient() {
const staged = []
const primitives = {
Modal: 'Modal',
SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl',
@@ -60,7 +61,15 @@ async function loadClient() {
}
const require = (id) => {
if (id === 'react') return { createElement: (type, props, ...children) => ({ type, props, children }) }
if (id === 'react') {
return {
createElement: (type, props, ...children) => ({ type, props, children }),
// Single-pass stand-ins: the tests render a component once and read the
// tree, so a hook only has to answer, not to re-render.
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
}
}
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`client.js required an unexpected module: ${id}`)
}
@@ -76,8 +85,9 @@ async function loadClient() {
/** 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 seen = { formNs: undefined, watched: undefined, registrations: [] }
const disposers = []
let currentSlot
const ctx = {
inject: (names, scope) => {
@@ -85,7 +95,7 @@ function drive(mod, { served = true, value = {} } = {}) {
configForms: {
get: (ns) => {
seen.formNs = ns
return { value }
return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true }
},
whileServed: (namespaces, register) => {
seen.watched = namespaces
@@ -97,12 +107,12 @@ function drive(mod, { served = true, value = {} } = {}) {
},
slots: {
inject: (name, register) => {
seen.slot = name
currentSlot = name
register()
currentSlot = undefined
},
register: (options, component) => {
seen.entry = options
seen.card = component
seen.registrations.push({ slot: currentSlot, options, component })
return () => {}
},
},
@@ -120,6 +130,13 @@ function drive(mod, { served = true, value = {} } = {}) {
return { seen, disposers }
}
/** The one registration a test cares about, by the slot it went into. */
function registrationFor(seen, slot) {
const found = seen.registrations.filter((entry) => entry.slot === slot)
assert.equal(found.length, 1, `expected exactly one registration into ${slot}`)
return found[0]
}
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')
@@ -130,44 +147,69 @@ test('the module is the one the Plugins page loads, and asks for the slots it re
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)
const card = registrationFor(seen, 'plugins.bundle.config')
// 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(card.options.key, 'dsh-facet')
assert.notEqual(seen.formNs, card.options.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(seen.slot, 'plugins.bundle.config')
assert.equal(card.slot, 'plugins.bundle.config')
assert.deepEqual(seen.watched, ['facet-policy'])
})
test('the context button rides the composer tool row, gated on the same namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const button = registrationFor(seen, 'conversation.input.left')
// An id, not a key: `conversation.input.left` is a list, and the row shows one
// entry per id.
assert.equal(button.options.id, 'facet-context')
assert.equal(typeof button.options.inject, 'function')
// It has to receive the settings form, because the panel writes through the
// very same document the plugin card does.
assert.equal(typeof button.options.inject().form, 'object')
})
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)
const card = registrationFor(seen, 'plugins.bundle.config')
// 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)
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, card.options.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')
// Neither the card nor the button: each would be a control that writes nowhere.
assert.deepEqual(seen.registrations, [])
})
test('the card stages exactly the fields the host marks volatile', async () => {
test('the card stages exactly the deployment fields the host marks volatile', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const declared = Object.entries(Config.dict)
const volatileFields = Object.entries(Config.dict)
.filter(([, schema]) => schema.meta.volatile === true)
.map(([key]) => key)
// `scopes` is volatile too, but it is not a deployment field and must not be a
// control on the card: it is per-session state, written by the context panel
// and keyed by session id. Staging it here would render a dictionary blob a
// user could not sensibly edit, and would put the panel's store on the one
// surface that edits the deployment.
assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it')
const declared = volatileFields.filter((key) => key !== 'scopes')
// 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(
@@ -204,11 +246,12 @@ test('the list field clears rather than storing an empty array that shadows the
test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: card } = registrationFor(seen, 'plugins.bundle.config')
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.')
assert.equal(typeof card, 'function')
assert.equal(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({
const tree = card({
view: 'page',
useFacetCard: (select) =>
select({
@@ -236,3 +279,127 @@ test('the card renders both views without reaching for anything it was not given
assert.equal(controls[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared')
})
test('the client half loads the packages whose slots it registers into', async () => {
const pkg = (await import('../package.json', { with: { type: 'json' } })).default
const inject = pkg.dsh.client.inject
// Registering into a slot is a dependency on the package that declares it.
// `slots.inject` waits for a slot rather than creating one, so a half that
// names a slot belonging to a package the boot graph has not loaded yet
// registers nothing at all — and silently, because nothing errors.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
})
// ── the context button and panel ─────────────────────────────────────────────
test('the context button appears for a facet session and nowhere else', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: button, options } = registrationFor(seen, 'conversation.input.left')
const { form } = options.inject()
const render = (preset) =>
button({
sessionId: 'session-1',
useSessions: (select) => select({ byId: { 'session-1': { projectionValues: { agentPreset: preset } } } }),
locked: false,
form,
})
assert.notEqual(render('facet'), null)
// Every other preset in the deployment is untouched: a control for a plugin
// that is not running would be worse than no control.
assert.equal(render('standard'), null)
assert.equal(render(undefined), null)
})
test('declaredFor normalises whatever the document holds', async () => {
const { module: mod } = await loadClient()
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 'src/x', extras: ['p'] } } } }, 'a'), {
focus: 'src/x',
extras: ['p'],
})
// A session with no entry, a document with no scopes, and junk in either all
// answer with the same empty declaration rather than throwing in a render.
const empty = { focus: '', extras: [] }
assert.deepEqual(mod.declaredFor({ value: {} }, 'a'), empty)
assert.deepEqual(mod.declaredFor({}, 'a'), empty)
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: 'nonsense' } } }, 'a'), empty)
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 7, extras: ['ok', 3, null] } } } }, 'a'), {
focus: '',
extras: ['ok'],
})
})
test('writeDeclaration writes one whole declaration, and removes an empty one', async () => {
const { module: mod } = await loadClient()
const calls = []
const form = { mutate: (ops) => (calls.push(ops), true) }
mod.writeDeclaration(form, 'session-1', { focus: ' src/x ', extras: ['p', ' ', 'q'] })
mod.writeDeclaration(form, 'session-1', { focus: '', extras: [] })
// One path, one write: a queued edit can never half-apply.
assert.deepEqual(calls[0], [{ op: 'set', path: ['scopes', 'session-1'], value: { focus: 'src/x', extras: ['p', 'q'] } }])
// Both fields empty means "the session directory", which is the schema default
// — so it is removed rather than stored as an empty override.
assert.deepEqual(calls[1], [{ op: 'unset', path: ['scopes', 'session-1'] }])
})
test('the panel renders the scope, the guidance, and a way to add and remove', async () => {
const { module: mod } = await loadClient()
const written = []
const form = {
getSnapshot: () => ({
status: 'ready',
writable: true,
revision: 4,
value: { mode: 'deny', scopes: { 'session-1': { focus: 'src/room', extras: ['CONTROLLER.md'] } } },
}),
subscribe: () => () => {},
mutate: (ops) => (written.push(ops), true),
}
const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', mode: 'deny', onClose: () => {} })
assert.equal(tree.type, 'Modal')
// Walk the tree for the text and controls a user would see.
const texts = []
const buttons = []
const inputs = []
const walk = (node) => {
if (typeof node === 'string') return texts.push(node)
if (node === null || node === undefined || typeof node !== 'object') return
if (Array.isArray(node)) return node.forEach(walk)
if (node.type === 'button') buttons.push(node)
if (node.type === 'input') inputs.push(node)
// Children arrive either as createElement arguments or, as the card does it,
// through the `children` prop.
if (Array.isArray(node.children)) node.children.forEach(walk)
walk(node.props?.children)
}
walk(tree)
const labelOf = (node) => (node.children ?? [])[0]
const prose = texts.join('\n')
assert.match(prose, /What this conversation may see/)
// The two tabs the preset picker cannot show for a custom preset live here.
assert.match(prose, /Mode details/)
assert.match(prose, /How to use/)
// The honest bit about this deployment, so the panel does not promise a
// question no approval channel can carry.
assert.match(prose, /no approval channel/)
assert.match(prose, /danger-full-access/)
// The declared scope is on screen, and the existing extra has its own control.
assert.equal(inputs[0].props.value, 'src/room')
assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1)
assert.ok(buttons.some((button) => labelOf(button) === 'Add'))
assert.ok(buttons.some((button) => labelOf(button) === 'Clear'))
assert.ok(buttons.some((button) => labelOf(button) === 'Set'))
})
+101 -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 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
@@ -98,6 +107,7 @@ before(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
facet = join(root, 'src', 'controllers', 'room')
await mkdir(facet, { recursive: true })
await mkdir(join(facet, 'inner'), { recursive: true })
await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true })
await mkdir(join(root, 'src', 'shared'), { recursive: true })
await writeFile(join(facet, 'index.ts'), 'export {}\n')
@@ -275,6 +285,41 @@ 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, /no approval channel/)
assert.match(text, /cannot be granted/)
// It names the mechanism that still works, which is the panel the user owns.
assert.match(text, /context panel/)
// 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() }), /no approval channel/)
})
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, /context panel/)
})
test('the facet tool reports the scope', async () => {
const { captured } = harness()
const value = await captured.tool.execute({ action: 'status' }, { agent: agent() })
@@ -304,6 +349,60 @@ test('the facet tool grants, narrows and clears', async () => {
assert.deepEqual(policy.grantsFor('session-1'), [])
})
test('the facet tool narrows inside the facet and refuses a folder outside it', async () => {
const { captured, policy } = harness()
const exec = { agent: agent() }
// Narrowing is the one scope change the agent may make unasked: it can only
// ever reduce what is visible, so there is nothing for a user to approve.
const narrowed = await captured.tool.execute({ action: 'focus', paths: ['inner'] }, exec)
assert.equal(narrowed.root, join(facet, 'inner'))
assert.equal(narrowed.facet, join(facet, 'inner'))
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
// A folder outside the scope already in force is a widening by another name,
// and widening is the user's decision, taken in their panel.
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['../../shared'] }, exec),
/outside the current facet/,
)
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['../other'] }, exec),
/outside the current facet/,
)
// A refused narrowing must not have moved anything.
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
})
test('the facet tool needs exactly one folder to focus on', async () => {
const { captured } = harness()
const exec = { agent: agent() }
await assert.rejects(() => captured.tool.execute({ action: 'focus' }, exec), /exactly one folder/)
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['inner', '../other'] }, exec),
/exactly one folder/,
)
})
test('reset lands on the scope the user declared, never back on the session directory', async () => {
const { captured, policy } = harness({
config: { mode: 'deny', scopes: { 'session-1': { focus: 'src/controllers/room' } } },
})
const exec = { agent: agent(root) }
// The user declared this conversation to be about the room controller, so the
// session is already narrower than the directory it was started in.
assert.equal(policy.scopeFor(agent(root)).root, facet)
await captured.tool.execute({ action: 'focus', paths: ['src/controllers/room/inner'] }, exec)
assert.equal(policy.scopeFor(agent(root)).root, join(facet, 'inner'))
// Clearing the agent's side has to land on the user's declaration. Landing on
// the session directory would make `reset` a widening.
await captured.tool.execute({ action: 'reset' }, exec)
assert.equal(policy.scopeFor(agent(root)).root, facet)
})
test('the facet tool refuses a call it cannot act on', async () => {
const { captured } = harness()
const exec = { agent: agent() }
+94
View File
@@ -93,3 +93,97 @@ test('prompt placements are carried, so the preset row needs no config', () => {
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7)
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8)
})
// ── per-session scope ────────────────────────────────────────────────────────
//
// The declaration is the user's, written from the browser panel; the narrowing
// is the agent's, held in memory. Everything below is about which of the two
// wins, and the answer is always "the narrower one, and never a stale agent
// focus that the user has since narrowed past".
test('a declared focus narrows the root, and the session directory stays the base', () => {
const policy = createFacetPolicy({
mode: 'deny',
scopes: { s1: { focus: 'src/controllers/room' } },
})
const scope = policy.scopeFor(agentAt('/w', 's1'))
assert.equal(scope.root, '/w/src/controllers/room')
// `cwd` is still where relative paths resolve from — a declaration written
// against the session keeps its meaning whatever the session focuses on.
assert.equal(scope.cwd, '/w')
})
test('a declaration is read live, so the panel needs no remount', () => {
const scopes = {}
const policy = createFacetPolicy({ mode: 'deny', scopes })
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
// What a panel write does to the running reference.
scopes.s1 = { focus: 'src/module' }
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/module')
})
test('declared extras join the deployment seed and the session grants', () => {
const policy = createFacetPolicy({
mode: 'deny',
extraRoots: ['seed'],
scopes: { s1: { focus: 'src/room', extras: ['shared', '/abs/common'] } },
})
policy.grant('s1', ['granted'], '/w')
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, [
'/w/seed',
'/w/shared',
'/abs/common',
'/w/granted',
])
})
test('an agent narrowing is honoured while it stays inside the declared root', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src' } } })
policy.narrowTo('s1', '/w/src/room')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
assert.equal(policy.narrowFor('s1'), '/w/src/room')
})
test('a narrowing the user has since narrowed past is dropped, not honoured', () => {
const scopes = { s1: { focus: 'src' } }
const policy = createFacetPolicy({ mode: 'deny', scopes })
policy.narrowTo('s1', '/w/src/room')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
// The user narrows to a sibling: the agent's focus is now outside the scope
// they declared. Honouring it would put the session back where the user had
// just decided it should not be.
scopes.s1 = { focus: 'src/other' }
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/other')
})
test('reset and forget drop the agent side and leave the declaration alone', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src', extras: ['shared'] } } })
policy.narrowTo('s1', '/w/src/room')
policy.grant('s1', ['granted'], '/w')
policy.reset('s1')
// Back to what the user declared — narrower than the session, never wider.
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
assert.deepEqual(policy.grantsFor('s1'), [])
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/shared'])
policy.narrowTo('s1', '/w/src/room')
policy.forget('s1')
assert.equal(policy.narrowFor('s1'), undefined)
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
})
test('junk in a declaration is ignored rather than thrown on', () => {
const policy = createFacetPolicy({
mode: 'deny',
scopes: { s1: { focus: ' ', extras: ['ok', '', 42, null] }, s2: 'not an object' },
})
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok'])
assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w')
})
+23 -1
View File
@@ -82,7 +82,7 @@ test('facetContextText carries the binding that does change', () => {
deny,
'facet: /w/src/room\n' +
'granted outside it: src/shared\n' +
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
)
// The binding is the only half that can follow a live edit, so the two modes
@@ -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 granted either. `canAsk: false` is the one
// input that collapses them, and it has to name the mechanism that still
// works — the context panel, which the user owns.
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 — this session has no approval channel/)
assert.match(text, /`facet action=add` cannot be granted/)
assert.match(text, /context panel/)
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'), '.')