13 Commits
Author SHA1 Message Date
gitea-actions 6b92656ed2 Release v0.3.0 [skip release] [skip actions] 2026-09-27 06:08:22 +00:00
NotExpectedYet 5d06829118 feat: pick the focus and the allowed paths from a directory listing
Release / release (push) Successful in 6s
Both scope controls were free-text boxes, and the vocabulary they asked the user
for is paths relative to the session folder. "Which folder is `../shared`" is the
one question a text box cannot answer, so the panel now shows the Host's own
directory: the `uiWorkspace.listDirectory` primitive the workspace picker's
browser drives, one level of child folders, a breadcrumb trail, and a
hidden-entries toggle. The path field stays editable inside the picker, because
the Host lists folders and an extra may legitimately name a file.

Picks are stored in the form that keeps their meaning. A folder at
`/work/root/src/shared` is declared `src/shared` when the session started in
`/work/root`, and `../shared` going up; only a path with nothing shared — another
drive, another tree — stays absolute. The Focus row shows that declared form,
with the resolved path in its title, which is what the agent will see.

`Set` is gone with the box it committed: the panel writes on pick, as every other
control here already did. `Clear` stays, because empty means the session folder —
the schema's own default rather than an override naming the same place.

The card was 380px wide and grew without bound, a single column of prose taller
than the viewport. It is 760px now, with a two-column grid that `auto-fit`
collapses to one on a narrow card, the reference prose folded into `<details>`,
and a scroll-bounded body. `Modal` sizes its own card from a hashed class in the
primitives' stylesheet, so this bundle installs one small stylesheet of its own
and outranks that rule with a doubled class selector — no `!important`, and
nothing reaching into a class name it does not own.

Two fixes came with it. The footer's "Deployment mode:" line read a slot prop
that never carried the mode, so it said "ask" in every deployment; it reads the
settings document now. And `uiWorkspace` is resolved with `ctx.get` at the call
rather than injected, because the panel is still the only way to declare scope in
a deployment with no directory browser: it keeps the panel and says what it
cannot do, instead of losing the button.
2026-09-27 07:05:47 +01:00
NotExpectedYet ef3df2d939 docs: commit each step as the work lands, not in one lump at the end
The subjects are the release notes, so a working tree is a queue of unwritten
sentences: a change left uncommitted until the end is a change with no note, and
a batch of unrelated changes cannot be split into honest subjects once the
reasoning behind each one is only in the transcript.

The second reason is the one that changes a version, so it is stated too: the
range picks its bump from whatever subjects it ends up with, and one `feat:`
batched in with a handful of fixes is a minor bump for all of them.

Also says outright that committing is local, because pushing to `master` is what
releases and that is a different decision.
2026-09-27 07:05:43 +01:00
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
gitea-actions 6e696f10a2 Release v0.1.3 [skip release] [skip actions] 2026-09-26 06:50:42 +00:00
NotExpectedYet 1be3a67b97 fix: put the settings card on the Plugins page with a browser half
Release / release (push) Successful in 6s
The `volatile()` markers made the Host serve the namespace, and that was as
far as it went: the Plugins page does not build a card from a `Config` schema.
It draws only what browser halves register into the slots it declares, which is
why dsh-slopo, dsh-recall and dsh-context all ship a `client/` — and why this
plugin, with two served fields, still showed nothing.

The card registers into `plugins.bundle.config` while
`configForms.whileServed` reports the namespace served. The two keys are
deliberately different strings and that is the trap in the file: the settings
document is filed under the profile entry id (`facet-policy`) while the card is
keyed by the package name (`dsh-facet`). Every sibling plugin happens to name
them identically. Binding the form to the wrong one yields a card that renders,
saves, and writes nowhere, so `test/client.test.js` pins both, holds the card's
field list to the schema's volatile fields, and refuses to register at all when
the namespace is not served.
2026-09-26 07:39:57 +01:00
gitea-actions e860ac2989 Release v0.1.2 [skip release] [skip actions] 2026-09-26 06:14:27 +00:00
NotExpectedYet 0e9f75cfab Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet
Release / release (push) Successful in 6s
2026-09-26 07:14:12 +01:00
NotExpectedYet 28f20a8aac fix: show the settings card, and make an edit actually take effect
`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.

It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.

A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
2026-09-26 07:13:33 +01:00
14 changed files with 2886 additions and 97 deletions

No files matched your search

+18
View File
@@ -21,6 +21,24 @@ release notes. Write it for that reader:
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
subject line is read; the body is not.
## Commit as the work lands
Commit each step as soon as it is finished rather than leaving it in the tree
until the end. Two reasons, and the second is the one that bites:
- An uncommitted change has no release note at all. The subject is the sentence a
user reads, so work that never becomes a commit never becomes a sentence.
- A tree carrying four unrelated changes cannot be split into four honest
subjects after the fact — the reasoning behind each one is only in the
transcript by then, and the range picks its bump from whatever subjects it
ends up with. One `feat:` batched in with a handful of fixes is a minor bump
for all of them.
Keep a subject to one change and the version follows from it.
A commit here is local. Pushing to `master` is what releases, so it stays a
separate, deliberate step.
## The release job owns the version and the changelog
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
+40
View File
@@ -2,6 +2,46 @@
Every release, newest first, generated from the commits in each release.
## v0.3.0 — 2026-09-27
### Added
- pick the focus and the allowed paths from a directory listing
### Documented
- commit each step as the work lands, not in one lump at the end
## 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
- put the settings card on the Plugins page with a browser half
## v0.1.2 — 2026-09-26
### Fixed
- show the settings card, and make an edit actually take effect
## v0.1.1 — 2026-09-26
### Documented
+115 -16
View File
@@ -103,23 +103,76 @@ 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
```
`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.
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
callback. The rule is not: it was registered at load as a plain string, and
nothing unregisters it, so the prompt would go on stating a rule that nothing
enforces. An `off` *deployment* is therefore settled at load — set it in the
patch. `ask` and `deny` swap freely in both directions.
`sectionOrder` and `contextOrder` are deliberately absent from the card. They
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.
### 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.
Both controls are a **directory listing**, not a text box: the panel's vocabulary
is paths relative to the session folder, and "which folder is `../shared`" is the
one question free text cannot answer. Pressing it opens a second dialog over the
panel, shaped like the host's own workspace browser — one level of child folders,
a breadcrumb trail, a hidden-entries toggle — driven through
`uiWorkspace.listDirectory`, the same Host primitive that browser uses. A pick is
stored in the form that keeps its meaning: `/work/root/src/shared` becomes
`src/shared` when the session started in `/work/root`. The picker's own path field
stays editable, because the Host lists folders and an extra may legitimately name
a file.
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
@@ -171,12 +224,15 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified:
- 42 tests (`npm test`) over the scope algebra, the policy state, and the guard —
including that containment is decided on canonical targets (a symlink inside
the facet pointing outside it is refused), that a prefix is not a parent
(`/a/room` does not contain `/a/roommate`), that a malformed tool call never
makes the guard throw, and that every `facet` result satisfies the tool's own
declared output schema.
- 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, 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`,
@@ -246,14 +302,57 @@ it.
```
index.js host row: the policy, published as ctx.facetPolicy
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/ 42 tests
test/ 78 tests
```
`client/client.js` is a browser half rather than a module the host imports, and
it is not optional decoration. The Plugins page does not build a card from a
`Config` schema — it draws only what browser halves register into the slots it
declares. `volatile()` on the host schema makes the namespace *servable*
(`settings.describe()` drops an entry with none, and the card registers only
while the namespace is served), but a schema alone puts nothing on the page.
The two keys in that file are deliberately different strings, and that is the
easy mistake to make here: the settings **document** is filed under the profile
entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
**card** is keyed by the package name (`dsh-facet`). Every sibling plugin
happens to name them identically, so binding the form to the wrong one produces
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
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.
The listing reaches the panel through `ctx.get('uiWorkspace')` rather than through
an injection, and the injection's `available()` is asked on every render. Both are
deliberate: `ctx.inject` would make the whole context button conditional on a
service the plugin does not own, and the panel is still the only way to declare
scope in a deployment with no directory browser — it would simply have no listing
to offer, which is what it says instead. The service is resolved at the call
because the renderer memoizes a registration's inject result, so a boolean read
there would freeze at whatever the first render saw.
The two dialogs are wider than the primitive's 380px card, which is fixed in the
primitives' own stylesheet. `client/client.js` installs one small stylesheet of
its own — the widths and a row hover — and passes each card a class through
`Modal`'s `className`. Every selector in it doubles the class it names, so it
outranks the single hashed class the primitive applies whatever order the two
stylesheets land in; there is no `!important` and nothing reaching into a class
name this bundle does not own. The panel's body is a two-column grid that
`auto-fit` collapses to one on a narrow card, and each dialog scrolls a bounded
body rather than growing past the viewport — the panel used to be a tall single
column of prose on a 380px card, which is what it looked like.
## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+1264
View File
File diff suppressed because it is too large. Load diff
+72 -10
View File
@@ -16,10 +16,12 @@
* published once here and consumed there, which is also why the preset can be
* swapped without touching any of this.
*
* There is deliberately no `settings` namespace. A facet grant is a statement
* about one conversation, so the only thing worth persisting is the deployment's
* standing grants, and those belong in the profile patch — the layer where this
* machine's facts already live.
* There is deliberately no `settings` *namespace* of our own: a facet grant is a
* statement about one conversation, so the only thing worth persisting is the
* deployment's standing grants, and those belong in the profile patch — the
* layer where this machine's facts already live. What the framework projects
* onto this row is the {@link Config} schema below, which is the same patch
* layer reached through the Web UI rather than a second place to store things.
*/
import Schema from '@deepseek-ai/schemastery'
@@ -28,21 +30,81 @@ import { createFacetPolicy } from './lib/policy.js'
export const name = 'dsh-facet'
/** Every configuration field this plugin exposes. */
/**
* Every configuration field this plugin exposes.
*
* `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 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
* the card, and a section cannot be re-ordered without a remount anyway.
*/
export const Config = Schema.object({
/**
* How a call outside the facet is handled. `ask` questions the user, `deny`
* refuses outright, `off` disables enforcement while leaving the preset in
* place — useful for telling a scoping problem from an unrelated one.
*/
mode: Schema.union(['ask', 'deny', 'off']).default('ask'),
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([]),
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),
+123 -21
View File
@@ -64,10 +64,48 @@ export const Config = Schema.object({
*/
export function apply(ctx, config) {
const policy = ctx.facetPolicy
/** @type {'ask'|'deny'|'off'} */
const mode = policy.mode
if (mode === 'off') return
// Read at the point of use, never captured. `mode` is a volatile field, so a
// settings edit updates the policy's reference in place and deliberately does
// not re-apply this plugin; a `const mode` here would answer with the value
// 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
// a reload, because a volatile-only edit never re-runs this function. `ask`
// and `deny` swap freely in both directions.
if (modeNow() === 'off') return
const sectionOrder = policy.sectionOrder ?? 100
const contextOrder = policy.contextOrder ?? 105
@@ -129,6 +167,9 @@ export function apply(ctx, config) {
const classify = async (exec) => {
if (exec.signal?.aborted) return undefined
const mode = modeNow()
if (mode === 'off') return undefined
// Widening is the one act that always needs the user, whatever the mode:
// it is explicit, deliberate, and the only thing standing between a scoped
// agent and an unscoped one.
@@ -155,11 +196,13 @@ export function apply(ctx, config) {
}
// The rule. `interpolate: false` because every value in it is already
// literal; there is nothing here for the harness to substitute.
// literal; there is nothing here for the harness to substitute. It carries no
// `mode`, because a section is registered once and cannot follow a live edit —
// see `facetRuleText`.
ctx.systemPrompt.section({
name: 'facet-scope',
order: Number(sectionOrder),
text: facetRuleText({ mode }),
text: facetRuleText(),
interpolate: false,
})
@@ -173,11 +216,17 @@ 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 })
return facetContextText({
cwd: scope.cwd,
root: scope.root,
extras: scope.extras,
mode: modeNow(),
canAsk: canAsk(context?.agent),
})
},
})
if (config.tool) ctx.tools.register(facetTool(ctx, policy, mode))
if (config.tool) ctx.tools.register(facetTool(ctx, policy))
// `next()` first, so an earlier policy plugin keeps its say: this listener
// only ever adds a question or a refusal to a call that was otherwise allowed.
@@ -197,36 +246,42 @@ 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.
* @param {'ask'|'deny'|'off'} mode - the deployment's enforcement mode.
*/
function facetTool(ctx, policy, mode) {
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: {
@@ -259,13 +314,16 @@ function facetTool(ctx, policy, mode) {
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)
@@ -276,7 +334,9 @@ function facetTool(ctx, policy, mode) {
ok: true,
root: scope.root,
facet: scope.root,
mode,
// Read here rather than captured at mount, so `status` reports the mode
// in force now instead of the one this session started under.
mode: String(policy.mode),
standing: policy.standing,
granted,
scope: scope.extras,
@@ -290,6 +350,48 @@ function facetTool(ctx, policy, mode) {
})
}
/**
* 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 = [
+201 -13
View File
@@ -16,7 +16,39 @@
* 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.
*
* Looked up by symbol rather than by importing cosmokit, because the ESM and CJS
* copies of that library are distinct module instances and `Symbol.for` is the
* only identity they share.
*/
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
/**
* Read one configuration field.
*
* A `volatile()` field is a *reference* in the running config, not a plain
* value: the framework resolves the schema through `Schema.resolve`, which wraps
* each volatile field in `createVolatile(...)` — `{get(), [write]}`. A
* non-volatile field, or a config object a test handed straight to
* {@link createFacetPolicy}, is the value itself. Both are accepted, so one code
* path serves a real profile and a direct call.
*
* Every read has to happen at the point of use rather than once at construction.
* A settings write commits into these references in place and deliberately does
* *not* re-apply the plugin, so anything captured at construction would never
* see the change — which is how a plugin ends up with a settings card that does
* nothing.
*
* @param {unknown} value - a field of the resolved config.
* @returns {unknown} the current value.
*/
export function configValue(value) {
return typeof value === 'object' && value !== null && VOLATILE_WRITE in value ? value.get() : value
}
/**
* @typedef {object} FacetScope
@@ -28,15 +60,95 @@ import { isAbsolute, resolve } from 'node:path'
/**
* Build the policy for one deployment.
*
* `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`, `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()
const standing = Array.isArray(config.extraRoots) ? config.extraRoots.filter(isPath) : []
/** @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.
*
* Not filtered once at construction: `extraRoots` is volatile, so an edit in
* the settings UI replaces the reference's contents without remounting this
* plugin, and the next scope resolution is expected to see it.
*
* @returns {string[]} configured paths, blanks dropped.
*/
function standingRoots() {
const configured = configValue(config.extraRoots)
return Array.isArray(configured) ? configured.filter(isPath) : []
}
/**
* The scope of one agent, or `undefined` when it has none.
@@ -45,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 = [...standing, ...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`.
@@ -102,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 []
}
@@ -121,15 +287,27 @@ export function createFacetPolicy(config) {
/** Drop a finished session's grants. Called on `session/disposed`. */
function forget(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
}
return {
mode: config.mode,
/** The enforcement mode. A getter, so a settings edit is answered on the next call. */
get mode() {
return configValue(config.mode)
},
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
sectionOrder: config.sectionOrder ?? 100,
contextOrder: config.contextOrder ?? 105,
standing: [...standing],
sectionOrder: configValue(config.sectionOrder) ?? 100,
contextOrder: configValue(config.contextOrder) ?? 105,
/** 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,
@@ -146,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
}
+43 -19
View File
@@ -118,25 +118,24 @@ export function isWideningCall(toolName, args) {
* treatment the sandbox policy and approval policy already get, for the same
* reason.
*
* @param {object} input
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
* @returns {string} prompt section text, or `''` to contribute nothing.
* It takes no configuration, and that is deliberate rather than an oversight.
* A system-prompt section is a plain string registered once; there is no
* callback to re-render it, and a settings write to a `volatile()` field
* updates the config reference *without* re-applying the plugin. So anything
* this function interpolated would freeze at whatever the value was at load —
* the prompt would go on describing the old `mode` while the guard enforced the
* new one. Everything that depends on `mode` therefore lives in the binding,
* which is a callback and therefore live.
*
* @returns {string} prompt section text.
*/
export function facetRuleText(input) {
const { mode } = input
if (mode === 'off') return ''
const reach =
mode === 'deny'
? 'A call outside it is refused. `facet action=add` asks the user to grant a path, and is the only way to widen the scope.'
: 'A call outside it is not refused: it is put to the user as a question, so say in one line what you need and why. If they agree, record it with `facet action=add` so the rest of the session does not have to ask again.'
export function facetRuleText() {
return [
'# Facet scope',
'',
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which.',
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which, and says how a call outside it is treated.',
'',
`Work inside the facet. ${reach}`,
'Work inside the facet.',
'',
'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.',
'',
@@ -164,28 +163,53 @@ export function displayPath(cwd, path) {
/**
* The mutable half of the facet, for the runtime-context snapshot.
*
* Kept to three short lines because it is re-sent as part of the conversation
* whenever it changes, and a scope statement that costs more than the scope is
* worth is a scope that does not pay for itself.
* Kept short because it is re-sent as part of the conversation whenever it
* changes, and a scope statement that costs more than the scope is worth is a
* scope that does not pay for itself. It carries one sentence more than the bare
* binding used to, because `mode` now belongs here: this is the only half that
* is re-rendered per request, so it is the only half that can follow a settings
* change. See {@link facetRuleText}.
*
* @param {object} input
* @param {string} input.cwd - the session working directory.
* @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`.
//
// `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
// and a relative form would be shorter. `facet: .` is true and useless: the
// model needs a path it can reason about, and the user reading an approval
// prompt needs to see which folder they are being asked about.
const lines = [`facet: ${root}`]
if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`)
lines.push(`outside the facet: ${mode === 'deny' ? 'refused' : 'ask the user'}`)
lines.push(`outside the facet: ${reach}`)
return lines.join('\n')
}
+14 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-facet",
"version": "0.1.1",
"version": "0.3.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.",
@@ -14,6 +14,9 @@
"types": "./lib/types/guard.d.ts",
"default": "./lib/guard.js"
},
"./client": {
"default": "./client/client.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
"./presets/facet.patch.yml": "./presets/facet.patch.yml",
"./package.json": "./package.json"
@@ -21,6 +24,7 @@
"files": [
"index.js",
"lib",
"client",
"presets",
"cordis.patch.yml",
"README.md"
@@ -40,6 +44,15 @@
"./cordis.patch.yml",
"./presets/facet.patch.yml"
]
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-workspace"
]
}
},
"keywords": [
+663
View File
@@ -0,0 +1,663 @@
/**
* The browser half: the two keys it binds, and the fields it stages.
*
* This file exists because of a bug that no host-side test could catch. Marking
* the schema's fields `volatile()` makes the Host *serve* the namespace, but the
* Plugins page draws only what a browser half registers — so the plugin showed no
* settings card at all, and every host-side test passed.
*
* The wiring is therefore the thing worth testing, and the two keys are the trap:
* the settings DOCUMENT is keyed by the profile entry id (`facet-policy`) while
* the CARD is keyed by the package name (`dsh-facet`). Every sibling plugin
* happens to name those identically, which is exactly why this drifted. A card
* bound to the wrong id renders, saves, and writes nowhere.
*
* `client/client.js` is a `window.__ModuleLoader__` module rather than an ES
* module — that is how the harness loads a browser half without a bundler — so
* the test stands up the two globals it reads and drives it from there.
*/
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { Config } from '../index.js'
/** Load the browser half with `window` and `require` faked, as the seed would. */
async function loadClient() {
const source = await readFile(new URL('../client/client.js', import.meta.url), 'utf8')
/** Every spec a card staged, in construction order. */
const staged = []
const primitives = {
Modal: 'Modal',
SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl',
// The listing's row glyph; a client half may only use what the primitives
// actually export, so the stand-in is named the same way.
IconFolderCloseRegular: 'IconFolderCloseRegular',
/** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class {
constructor(scope, specs) {
this.scope = scope
this.specs = specs
staged.push(...specs)
}
bind(project) {
this.project = project
return { project }
}
shell() {
return { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false }
}
field(name) {
const spec = this.specs.find((candidate) => candidate.field === name)
return { text: spec?.format(this.scope.value?.[name]) ?? '', overridden: false, invalid: false }
}
actions() {
return { edit: () => {}, resetField: () => {}, save: () => {}, discard: () => {} }
}
dispose() {}
},
}
const require = (id) => {
if (id === 'react') {
return {
createElement: (type, props, ...children) => ({ type, props, children }),
// 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}`)
}
let registered
const loader = { load: (value) => (registered = value) }
// eslint-disable-next-line no-new-func -- the file is a ModuleLoader script, not an ES module.
new Function('window', 'require', source)({ __ModuleLoader__: loader }, require)
assert.notEqual(registered, undefined, 'client.js never called window.__ModuleLoader__.load')
return { module: registered.factory(require), staged, primitives }
}
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {}, workspace } = {}) {
const seen = { formNs: undefined, watched: undefined, registrations: [] }
const disposers = []
let currentSlot
// The optional service the context panel browses through. `get` is the
// Cordis read that answers `undefined` rather than throwing, which is what
// the plugin relies on to keep the panel in a deployment with no browser.
const get = (name) => (name === 'uiWorkspace' ? workspace : undefined)
const ctx = {
get,
inject: (names, scope) => {
const scoped = {
get,
configForms: {
get: (ns) => {
seen.formNs = ns
return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true }
},
whileServed: (namespaces, register) => {
seen.watched = namespaces
// The real `whileServed` registers only while the Host serves the
// namespace; `served` is what the Host's `settings.describe` answers.
if (!served) return () => {}
return register(new Set(namespaces))
},
},
slots: {
inject: (name, register) => {
currentSlot = name
register()
currentSlot = undefined
},
register: (options, component) => {
seen.registrations.push({ slot: currentSlot, options, component })
return () => {}
},
},
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') disposers.push(dispose)
return dispose
},
}
scope(scoped)
},
}
mod.apply(ctx)
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')
assert.deepEqual(mod.inject, ['slots'])
assert.equal(typeof mod.apply, 'function')
})
test('the form binds to the profile entry id while the card registers under the package name', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
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(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(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 — and the browse face, because the
// panel's two controls are directory listings.
assert.equal(typeof button.options.inject().form, 'object')
assert.equal(typeof button.options.inject().browse.list, 'function')
})
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, 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')
// Neither the card nor the button: each would be a control that writes nowhere.
assert.deepEqual(seen.registrations, [])
})
test('the card stages exactly the deployment fields the host marks volatile', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
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(
staged.map((spec) => spec.field).sort(),
declared.sort(),
'the card and the host schema have drifted apart',
)
})
test('the choice field refuses a value the schema would reject, and clears on empty', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const mode = staged.find((spec) => spec.field === 'mode')
assert.equal(mode.parse('deny').value, 'deny')
assert.equal(mode.parse(' DENY ').value, 'deny')
assert.deepEqual(mode.parse(''), { kind: 'clear' })
// Undefined marks the draft invalid, which blocks the save rather than
// coercing a typo into a mode the Host would reject anyway.
assert.equal(mode.parse('maybe'), undefined)
})
test('the list field clears rather than storing an empty array that shadows the default', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const roots = staged.find((spec) => spec.field === 'extraRoots')
assert.deepEqual(roots.parse('a, b\nc').value, ['a', 'b', 'c'])
assert.deepEqual(roots.parse(' '), { kind: 'clear' })
assert.equal(roots.format(['a', 'b']), 'a, b')
assert.equal(roots.format(undefined), '')
})
test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: card } = registrationFor(seen, 'plugins.bundle.config')
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 = card({
view: 'page',
useFacetCard: (select) =>
select({
available: true,
writable: true,
dirty: false,
invalid: false,
saving: false,
failed: false,
mode: { text: 'deny', overridden: false, invalid: false },
extraRoots: { text: 'src/shared', overridden: true, invalid: false },
}),
edit: () => {},
resetField: () => {},
save: () => {},
discard: () => {},
})
// The form frame, with the segmented control reading the staged mode. Children
// ride the `children` prop, which is how the platform's own cards pass them.
assert.equal(tree.type, 'SettingsForm')
const controls = tree.props.children.children
assert.equal(controls[0].type, 'div')
assert.equal(controls[1].type, 'SettingsValueField')
assert.equal(controls[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared')
})
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')
// Not a slot this half registers into: the panel's directory listing drives
// `uiWorkspace.listDirectory`, so the package that provides it has to be in
// the boot graph before this half asks for it.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-workspace'), 'browses through uiWorkspace')
})
// ── 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',
cwd: '/work/root',
browse: { available: () => true, list: () => Promise.resolve() },
onClose: () => {},
})
assert.equal(tree.type, 'Modal')
// The class is the whole width fix: the primitive's own card is 380px and it
// takes only a class name, so the stylesheet this bundle installs outranks it.
assert.equal(tree.props.className, mod.PANEL_CLASS)
// Walk the tree for the text and controls a user would see.
const texts = textsOf(tree)
const buttons = buttonsOf(tree)
const inputs = []
walkTree(tree, (node) => {
if (node.type === 'input') inputs.push(node)
})
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,
// folded away so the controls are not buried under them.
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.ok(texts.includes('src/room'), 'the focus is shown as the declared path')
assert.ok(texts.some((text) => text.includes('/work/root')), 'the session folder relative to what is named')
assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1)
// Both controls are the listing. `Set` is gone with the box it committed:
// picking writes the declaration straight away, as every other control here
// already did.
assert.ok(buttons.some((button) => labelOf(button) === 'Browse\u2026'))
assert.ok(buttons.some((button) => labelOf(button) === 'Clear'))
assert.ok(buttons.some((button) => labelOf(button) === '+ Add folder or path\u2026'))
assert.ok(!buttons.some((button) => labelOf(button) === 'Set'))
assert.deepEqual(inputs, [], 'the panel itself has no free-text box left')
})
test('a deployment with no directory browser keeps the panel and says so', async () => {
const { module: mod } = await loadClient()
const form = {
getSnapshot: () => ({ status: 'ready', writable: true, revision: 1, value: { mode: 'deny', scopes: {} } }),
subscribe: () => () => {},
mutate: () => true,
}
const tree = mod.FacetContextPanel({
form,
sessionId: 's',
cwd: '',
browse: mod.directoryBrowse({ get: () => undefined }),
onClose: () => {},
})
const texts = textsOf(tree)
const label = (button) => (button.children ?? [])[0]
// The panel is the only way to declare scope, so it renders without a
// browser: the listing affordances go dead, the explanation stays.
assert.deepEqual(
buttonsOf(tree)
.filter((button) => label(button) === 'Browse\u2026')
.map((button) => button.props.disabled),
[true],
)
assert.deepEqual(
buttonsOf(tree)
.filter((button) => label(button) === '+ Add folder or path\u2026')
.map((button) => button.props.disabled),
[true],
)
assert.ok(texts.some((text) => text.includes('no directory browser')))
// The mode is read from the document rather than from a slot prop that never
// carried it, so the line does not claim "ask" whatever the setting is.
assert.ok(texts.some((text) => text.includes('Deployment mode: deny')))
})
// ── the directory listing ────────────────────────────────────────────────────
/** A listing shaped the way the Host's browse primitive answers. */
function listingFixture() {
return {
path: '/work/root/src',
home: '/home/dev',
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'work', path: '/work', hidden: false },
{ name: 'root', path: '/work/root', hidden: false },
{ name: 'src', path: '/work/root/src', hidden: false },
],
entries: [
{ name: 'shared', path: '/work/root/src/shared', hidden: false },
{ name: '.git', path: '/work/root/src/.git', hidden: true },
],
truncated: false,
}
}
/** Let the model's own promise chain settle. */
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0))
}
/** Every button in a rendered tree, in document order. */
function buttonsOf(tree) {
const found = []
walkTree(tree, (node) => {
if (node.type === 'button') found.push(node)
})
return found
}
/** Every string in a rendered tree, in document order. */
function textsOf(tree) {
const found = []
walkTree(tree, (node) => {
if (typeof node === 'string') found.push(node)
})
return found
}
/** Walk a rendered tree the way it was built: children as arguments or as a prop. */
function walkTree(node, visit) {
if (typeof node === 'string') return visit(node)
if (node === null || node === undefined || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) walkTree(child, visit)
return
}
visit(node)
if (Array.isArray(node.children)) node.children.forEach((child) => walkTree(child, visit))
walkTree(node.props?.children, visit)
}
test('a path is declared relative to the session folder wherever it can be', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.relativeTo('/work/root', '/work/root'), '', 'the session folder itself has no name of its own')
assert.equal(mod.relativeTo('/work/root', '/work/root/src/shared'), 'src/shared')
assert.equal(mod.relativeTo('/work/root/dsh-facet', '/work/root'), '..')
assert.equal(mod.relativeTo('/work/root/dsh-facet/lib', '/work/root'), '../..')
// Nothing shared: another tree, or another drive. Absolute is the only
// spelling that still names it.
assert.equal(mod.relativeTo('/work/root', '/srv/other'), '/srv/other')
assert.equal(mod.relativeTo('C:\\work\\root', 'D:\\work\\root'), 'D:\\work\\root')
assert.equal(mod.relativeTo('', '/srv/other'), '/srv/other')
// Windows separators split and join like POSIX ones.
assert.equal(mod.relativeTo('C:\\work\\root', 'C:\\work\\other'), '../other')
assert.equal(mod.absoluteFrom('/work/root', 'src/shared'), '/work/root/src/shared')
assert.equal(mod.absoluteFrom('/work/root', '/srv/other'), '/srv/other')
assert.equal(mod.absoluteFrom('/work/root', ''), '', 'empty stays empty: it means the session folder')
assert.equal(mod.absoluteFrom('', 'src/shared'), 'src/shared', 'with no folder to join, a relative path is left alone')
assert.equal(mod.isAbsolutePath('C:\\x'), true)
assert.equal(mod.isAbsolutePath('\\\\server\\share'), true)
assert.equal(mod.isAbsolutePath('src/x'), false)
})
test('the browse face is read from uiWorkspace at the call, not at registration', async () => {
const { module: mod } = await loadClient()
// Absent service: the probe answers false and the call rejects with something
// the picker can show rather than a TypeError from an undefined property.
const missing = mod.directoryBrowse({ get: () => undefined })
assert.equal(missing.available(), false)
await assert.rejects(missing.list('/tmp'), /no directory browser/)
const calls = []
const present = mod.directoryBrowse({
get: () => ({
listDirectory: (path, signal) => {
calls.push([path, signal])
return Promise.resolve('listing')
},
}),
})
assert.equal(present.available(), true)
assert.equal(await present.list('/tmp', 'signal'), 'listing')
assert.deepEqual(calls, [['/tmp', 'signal']])
})
test('the picker model lists a level, supersedes a stale one, and keeps a typed path on failure', async () => {
const { module: mod } = await loadClient()
const asked = []
const model = new mod.DirectoryPickerModel({
list: (path) => {
asked.push(path)
return Promise.resolve(listingFixture())
},
root: '/work/root',
start: '/work/root/src',
})
await settle()
assert.deepEqual(asked, ['/work/root/src'], 'the opening level is listed, not guessed at')
assert.equal(model.getSnapshot().listing.path, '/work/root/src')
assert.equal(model.getSnapshot().draft, '/work/root/src', 'the field follows a successful listing')
assert.equal(model.getSnapshot().loading, false)
// A second navigation wins even when the first is still in flight: the
// sequence number is what stops an early listing landing on a later view.
model.open('/work/root/other')
model.setDraft('notes/CONTROLLER.md')
// A failure keeps the level on screen and the path as typed: the Host lists
// folders, and the extras legitimately name a file.
const failing = new mod.DirectoryPickerModel({
list: () => Promise.reject(Object.assign(new Error('transport'), { rpcError: { message: 'directory-unreadable' } })),
root: '/work/root',
start: 'notes/CONTROLLER.md',
})
await settle()
assert.equal(failing.getSnapshot().error, 'directory-unreadable', 'the Host business message is preferred')
assert.equal(failing.getSnapshot().draft, 'notes/CONTROLLER.md')
})
test('the picker lists folders, hides hidden ones until asked, and answers in declared form', async () => {
const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
list: () => Promise.resolve(listingFixture()),
root: '/work/root',
start: '/work/root/src',
})
await settle()
const picked = []
const render = () =>
mod.DirectoryPicker({
model,
root: '/work/root',
target: 'focus',
disabled: false,
onClose: () => {},
onPick: (path) => picked.push(path),
})
const tree = render()
assert.equal(tree.type, 'Modal')
assert.equal(tree.props.className, mod.PICKER_CLASS)
assert.equal(tree.props.title, 'Choose the focus folder')
// The level is the rows, one per listed folder; the trail above them is the
// ancestry, and a hidden entry stays hidden until the toggle asks for it.
const rowTitles = (node) =>
buttonsOf(node)
.filter((button) => button.props.className === mod.ROW_CLASS)
.map((button) => button.props.title)
assert.deepEqual(rowTitles(tree), ['/work/root/src/shared'])
assert.ok(
buttonsOf(tree).some((button) => button.props.title === '/work/root' && (button.children ?? [])[0] === 'root'),
'the trail offers the ancestry as jump targets',
)
// The footer's own action takes the field's value and hands it back in the
// declared form, so the workspace can move without the grant meaning
// something else.
const footer = tree.props.footer
const pickButton = footer.find((entry) => (entry.children ?? [])[0] === 'Set focus')
assert.notEqual(pickButton, undefined, 'the footer commits the picked path')
pickButton.props.onClick()
assert.deepEqual(picked, ['src'])
model.setShowHidden(true)
assert.deepEqual(rowTitles(render()), ['/work/root/src/shared', '/work/root/src/.git'])
// A row is a way down, not a pick: standing in the folder is what chooses it.
model.open('/work/root/other')
await settle()
})
+130 -3
View File
@@ -18,6 +18,15 @@ import { after, before, test } from 'node:test'
import { apply as guard } from '../lib/guard.js'
import { createFacetPolicy } from '../lib/policy.js'
import { Config } from '../index.js'
/**
* The symbol cosmokit marks a volatile config reference with. A settings write
* reaches a running plugin as `target[write](value)` — that is literally what
* `updateVolatile` does — so a test can commit an edit the way the framework
* does, without a settings service.
*/
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
/** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */
const fsLikeLocal = {
@@ -49,13 +58,22 @@ const fsLikeLocal = {
},
}
/** Capture what `apply` registered, so the test can drive it. */
function harness({ mode = 'ask', extraRoots = [], tool = true } = {}) {
const policy = createFacetPolicy({ mode, extraRoots })
/**
* 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
@@ -89,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')
@@ -217,6 +236,25 @@ test('off mode registers nothing', () => {
assert.equal(captured.tool, undefined)
})
test('a live mode edit decides the next call, without the guard being remounted', async () => {
// The loader hands the guard the schema-resolved config, so `mode` arrives as
// a volatile reference rather than a string. This drives that exact shape.
const config = Config({ mode: 'ask' })
const { captured } = harness({ config })
const outside = { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() }
assert.equal((await dispatch(captured, outside)).kind, 'ask')
// What a settings write does: commit into the reference in place, and
// deliberately do not re-apply the plugin. One mount, three answers.
config.mode[VOLATILE_WRITE]('deny')
assert.equal((await dispatch(captured, outside)).kind, 'deny')
// `off` silences the guard entirely, which is the whole meaning of the mode.
config.mode[VOLATILE_WRITE]('off')
assert.equal((await dispatch(captured, outside)).kind, 'allow')
})
test('the guard adds to a decision, never overrides one', async () => {
const { captured } = harness()
const denied = await dispatch(captured, {
@@ -247,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() })
@@ -276,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() }
+61 -2
View File
@@ -10,6 +10,18 @@ import assert from 'node:assert/strict'
import { test } from 'node:test'
import { apply, Config } from '../index.js'
import { configValue } from '../lib/policy.js'
/**
* The symbol cosmokit marks a volatile config reference with, and the writer it
* carries. A settings write reaches a running plugin through exactly this
* function — `updateVolatile(target, source)` is `target[write](source.get())` —
* so a test can commit an edit the same way the framework does.
*/
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
/** Whether a field arrived as a live reference rather than a plain value. */
const isReference = (value) => typeof value === 'object' && value !== null && VOLATILE_WRITE in value
/** The smallest context this row touches. */
function harness() {
@@ -28,12 +40,59 @@ function harness() {
test('the schema fills every default, so a bare row is a working row', () => {
const config = Config({})
assert.equal(config.mode, 'ask')
assert.deepEqual(config.extraRoots, [])
assert.equal(configValue(config.mode), 'ask')
assert.deepEqual(configValue(config.extraRoots), [])
assert.equal(config.sectionOrder, 100)
assert.equal(config.contextOrder, 105)
})
test('the editable fields are volatile, which is the only reason they have a settings card', () => {
const config = Config({})
// 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
// dropping either of these markers silently removes the card from the UI.
assert.equal(isReference(config.mode), true)
assert.equal(isReference(config.extraRoots), true)
// Placement is settled once at load: not settings anyone edits live, and not
// on the card.
assert.equal(isReference(config.sectionOrder), false)
assert.equal(isReference(config.contextOrder), false)
})
test('a settings edit is answered on the next call, without the plugin being re-applied', () => {
const provided = []
let applies = 0
const config = Config({ mode: 'ask', extraRoots: ['/w/src/shared'] })
apply(
{
provide: (name, value) => {
applies += 1
provided.push({ name, value })
},
on: () => {},
},
config,
)
const policy = provided[0].value
assert.equal(policy.mode, 'ask')
assert.deepEqual(policy.standing, ['/w/src/shared'])
// What the framework does on a settings write: it commits into the running
// references and deliberately does NOT re-apply the plugin. Everything the
// policy reports has to follow from the reference alone.
config.mode[VOLATILE_WRITE]('deny')
config.extraRoots[VOLATILE_WRITE](['/w/src/shared', '/w/src/types'])
assert.equal(applies, 1)
assert.equal(policy.mode, 'deny')
assert.deepEqual(policy.standing, ['/w/src/shared', '/w/src/types'])
assert.deepEqual(
policy.scopeFor({ session: { id: 's1', header: { cwd: '/w/src/room' } } }).extras,
['/w/src/shared', '/w/src/types'],
)
})
test('a bad mode fails loudly at load rather than at the first file call', () => {
assert.throws(() => Config({ mode: 'maybe' }))
})
+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')
})
+48 -12
View File
@@ -51,33 +51,69 @@ test('isWideningCall is true only for the one act that needs the user', () => {
})
test('facetRuleText states the rule and never the binding', () => {
const ask = facetRuleText({ mode: 'ask' })
assert.match(ask, /put to the user as a question/)
// The two modes must not read alike: ask must not claim the call is refused.
assert.doesNotMatch(ask, /A call outside it is refused/)
const rule = facetRuleText()
const deny = facetRuleText({ mode: 'deny' })
assert.match(deny, /A call outside it is refused/)
// Mode-neutral by construction. A prompt section is registered once as a plain
// string, and a `volatile()` edit updates the config reference without
// re-applying the plugin — so a `mode` interpolated here would freeze at load
// while the guard went on enforcing the new one. The mode-specific sentence
// lives in the binding, which is a callback.
assert.match(rule, /Work inside the facet\./)
assert.doesNotMatch(rule, /refused|put to the user as a question/)
assert.match(rule, /Never widen the scope on your own initiative/)
// The rule must not name a path: a changing system prompt invalidates the
// prompt cache for the whole conversation.
assert.doesNotMatch(ask, /\/|src\//)
assert.doesNotMatch(rule, /\/|src\//)
assert.equal(facetRuleText({ mode: 'off' }), '')
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
})
test('facetContextText carries the binding that does change', () => {
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
assert.equal(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
'facet: /w/src/room\noutside the facet: ask the user',
ask,
'facet: /w/src/room\n' +
'outside the facet: 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',
)
const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' })
assert.equal(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }),
'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused',
deny,
'facet: /w/src/room\n' +
'granted outside it: src/shared\n' +
'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
// have to read differently here — they no longer differ in the rule.
assert.notEqual(ask, deny)
// Unscoped sessions contribute nothing rather than an empty heading.
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
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', () => {