Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8829cb56 | ||
|
|
266e90aa2d | ||
|
|
d9caf4eeec | ||
|
|
f36d46efa1 | ||
|
|
a1c8ecb1e8 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
|
||||
+98
-11
@@ -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`
|
||||
: 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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
@@ -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'), '.')
|
||||
|
||||
Reference in new issue
Block a user