Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b92656ed2 | ||
|
|
5d06829118 | ||
|
|
ef3df2d939 | ||
|
|
ef8829cb56 | ||
|
|
266e90aa2d | ||
|
|
d9caf4eeec | ||
|
|
f36d46efa1 | ||
|
|
a1c8ecb1e8 | ||
|
|
6e696f10a2 | ||
|
|
1be3a67b97 | ||
|
|
e860ac2989 | ||
|
|
0e9f75cfab | ||
|
|
28f20a8aac |
No files matched your search
@@ -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
|
`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.
|
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
|
## The release job owns the version and the changelog
|
||||||
|
|
||||||
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
|
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
|
||||||
|
|||||||
@@ -2,6 +2,46 @@
|
|||||||
|
|
||||||
Every release, newest first, generated from the commits in each release.
|
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
|
## v0.1.1 — 2026-09-26
|
||||||
|
|
||||||
### Documented
|
### Documented
|
||||||
|
|||||||
@@ -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
|
### 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
|
The host row's config is where this *deployment's* facts belong — the profile's
|
||||||
`cordis.patch.yml`:
|
own `cordis.patch.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- id: facet-policy
|
- id: facet-policy
|
||||||
name: dsh-facet
|
name: dsh-facet
|
||||||
config:
|
config:
|
||||||
mode: ask # ask | deny | off
|
mode: ask # ask | deny | off
|
||||||
extraRoots: # always in scope, in every session
|
extraRoots: [] # optional: a seed of paths always in scope, every session
|
||||||
- ../../shared # relative to each session's own directory
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`extraRoots` is for code a module legitimately always depends on. Anything you
|
Both are also on the plugin's card under **Plugins** in the Web UI, which is the
|
||||||
grant by hand is per-conversation and is never persisted — "this session may
|
same layer reached a different way: a settings write rewrites this entry in the
|
||||||
also see `src/shared`" is a statement about one conversation, and writing it down
|
profile patch rather than storing anything new. An edit there is live — the guard
|
||||||
would quietly widen every future one, which is the failure this plugin exists to
|
reads `mode` and `extraRoots` on every call, so a change decides the next file
|
||||||
prevent.
|
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
|
## The compaction row
|
||||||
|
|
||||||
@@ -171,12 +224,15 @@ Honest boundaries, because each one is a decision rather than an oversight:
|
|||||||
|
|
||||||
Verified:
|
Verified:
|
||||||
|
|
||||||
- 42 tests (`npm test`) over the scope algebra, the policy state, and the guard —
|
- 73 tests (`npm test`) over the scope algebra, the policy state, the guard, and
|
||||||
including that containment is decided on canonical targets (a symlink inside
|
the browser half — including that containment is decided on canonical targets
|
||||||
the facet pointing outside it is refused), that a prefix is not a parent
|
(a symlink inside the facet pointing outside it is refused), that a prefix is
|
||||||
(`/a/room` does not contain `/a/roommate`), that a malformed tool call never
|
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
|
||||||
makes the guard throw, and that every `facet` result satisfies the tool's own
|
call never makes the guard throw, that a live `mode` edit decides the next call
|
||||||
declared output schema.
|
without the guard being remounted, 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
|
- The bundle's patch layers parse, merge and land correctly against the real
|
||||||
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
||||||
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
||||||
@@ -246,14 +302,57 @@ it.
|
|||||||
|
|
||||||
```
|
```
|
||||||
index.js host row: the policy, published as ctx.facetPolicy
|
index.js host row: the policy, published as ctx.facetPolicy
|
||||||
|
client/client.js browser half: the settings card, and the context panel
|
||||||
lib/policy.js the stateful half — scope resolution, grants
|
lib/policy.js the stateful half — scope resolution, grants
|
||||||
lib/scope.js the pure half — argument extraction, text, reasons
|
lib/scope.js the pure half — argument extraction, text, reasons
|
||||||
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
||||||
presets/facet.patch.yml the `facet` agent preset
|
presets/facet.patch.yml the `facet` agent preset
|
||||||
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
||||||
test/ 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
|
## Also in this repository
|
||||||
|
|
||||||
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
||||||
|
|||||||
+1264
File diff suppressed because it is too large.
Load diff
@@ -16,10 +16,12 @@
|
|||||||
* published once here and consumed there, which is also why the preset can be
|
* published once here and consumed there, which is also why the preset can be
|
||||||
* swapped without touching any of this.
|
* swapped without touching any of this.
|
||||||
*
|
*
|
||||||
* There is deliberately no `settings` namespace. A facet grant is a statement
|
* There is deliberately no `settings` *namespace* of our own: a facet grant is a
|
||||||
* about one conversation, so the only thing worth persisting is the deployment's
|
* statement about one conversation, so the only thing worth persisting is the
|
||||||
* standing grants, and those belong in the profile patch — the layer where this
|
* deployment's standing grants, and those belong in the profile patch — the
|
||||||
* machine's facts already live.
|
* 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'
|
import Schema from '@deepseek-ai/schemastery'
|
||||||
@@ -28,21 +30,81 @@ import { createFacetPolicy } from './lib/policy.js'
|
|||||||
|
|
||||||
export const name = 'dsh-facet'
|
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({
|
export const Config = Schema.object({
|
||||||
/**
|
/**
|
||||||
* How a call outside the facet is handled. `ask` questions the user, `deny`
|
* How a call outside the facet is handled. `ask` questions the user, `deny`
|
||||||
* refuses outright, `off` disables enforcement while leaving the preset in
|
* refuses outright, `off` disables enforcement while leaving the preset in
|
||||||
* place — useful for telling a scoping problem from an unrelated one.
|
* 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
|
* A deployment-wide seed of paths always in scope, for code a module
|
||||||
* types, a common test harness. Relative paths resolve against each session's
|
* legitimately depends on — shared types, a common test harness.
|
||||||
* own directory, so the same list is meaningful in every workspace.
|
*
|
||||||
|
* 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. */
|
/** Placement of the invariant rule in the system prompt. */
|
||||||
sectionOrder: Schema.number().default(100),
|
sectionOrder: Schema.number().default(100),
|
||||||
|
|||||||
+122
-20
@@ -64,10 +64,48 @@ export const Config = Schema.object({
|
|||||||
*/
|
*/
|
||||||
export function apply(ctx, config) {
|
export function apply(ctx, config) {
|
||||||
const policy = ctx.facetPolicy
|
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 sectionOrder = policy.sectionOrder ?? 100
|
||||||
const contextOrder = policy.contextOrder ?? 105
|
const contextOrder = policy.contextOrder ?? 105
|
||||||
@@ -129,6 +167,9 @@ export function apply(ctx, config) {
|
|||||||
const classify = async (exec) => {
|
const classify = async (exec) => {
|
||||||
if (exec.signal?.aborted) return undefined
|
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:
|
// 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
|
// it is explicit, deliberate, and the only thing standing between a scoped
|
||||||
// agent and an unscoped one.
|
// 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
|
// 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({
|
ctx.systemPrompt.section({
|
||||||
name: 'facet-scope',
|
name: 'facet-scope',
|
||||||
order: Number(sectionOrder),
|
order: Number(sectionOrder),
|
||||||
text: facetRuleText({ mode }),
|
text: facetRuleText(),
|
||||||
interpolate: false,
|
interpolate: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,11 +216,17 @@ export function apply(ctx, config) {
|
|||||||
text: (context) => {
|
text: (context) => {
|
||||||
const scope = policy.scopeFor(context?.agent)
|
const scope = policy.scopeFor(context?.agent)
|
||||||
if (scope === undefined) return ''
|
if (scope === undefined) return ''
|
||||||
return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode })
|
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
|
// `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.
|
// 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
|
* `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
|
* 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
|
* 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`.
|
* 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 {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
|
||||||
* @param {object} policy - the `facetPolicy` service.
|
* @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({
|
return defineTool({
|
||||||
name: FACET_TOOL,
|
name: FACET_TOOL,
|
||||||
description:
|
description:
|
||||||
'Read this session\u2019s facet scope, or ask the user to grant an extra path. A facet is the folder the ' +
|
'Read this session\u2019s facet scope, narrow it to one folder, or ask the user for an extra path. A facet is ' +
|
||||||
'conversation was started in; the session is scoped to it. Use `status` to see the current scope. Use `add` ' +
|
'the folder the conversation is scoped to; `status` reports it. Use `focus` when the work has settled on one ' +
|
||||||
'only when you actually need a file outside the facet — it asks the user for permission, and the grant lasts ' +
|
'folder inside the facet — narrowing is always allowed and takes effect at once. Use `add` only when you ' +
|
||||||
'for the rest of the conversation. Never call `add` for a path you merely expect to need.',
|
'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: {
|
parameters: {
|
||||||
action: {
|
action: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: true,
|
required: true,
|
||||||
description: 'One of: status, add, remove, reset.',
|
description: 'One of: status, focus, add, remove, reset.',
|
||||||
},
|
},
|
||||||
paths: {
|
paths: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
description:
|
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: {
|
output: {
|
||||||
@@ -259,13 +314,16 @@ function facetTool(ctx, policy, mode) {
|
|||||||
const problem =
|
const problem =
|
||||||
(action === 'add' || action === 'remove') && requested.length === 0
|
(action === 'add' || action === 'remove') && requested.length === 0
|
||||||
? `facet: action "${action}" needs \`paths\``
|
? `facet: action "${action}" needs \`paths\``
|
||||||
: !['status', 'add', 'remove', 'reset'].includes(action)
|
: action === 'focus' && requested.length !== 1
|
||||||
? `facet: unknown action "${action}"; expected status, add, remove or reset`
|
? '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
|
: undefined
|
||||||
if (problem !== undefined) throw new Error(problem)
|
if (problem !== undefined) throw new Error(problem)
|
||||||
|
|
||||||
const before = policy.grantsFor(sessionId)
|
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 === 'remove') policy.revoke(sessionId, requested, cwd)
|
||||||
else if (action === 'reset') policy.reset(sessionId)
|
else if (action === 'reset') policy.reset(sessionId)
|
||||||
|
|
||||||
@@ -276,7 +334,9 @@ function facetTool(ctx, policy, mode) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
root: scope.root,
|
root: scope.root,
|
||||||
facet: 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,
|
standing: policy.standing,
|
||||||
granted,
|
granted,
|
||||||
scope: scope.extras,
|
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. */
|
/** Model-facing text for one `facet` result. Pure. */
|
||||||
function renderFacet(value) {
|
function renderFacet(value) {
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
+201
-13
@@ -16,7 +16,39 @@
|
|||||||
* exists to prevent.
|
* 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
|
* @typedef {object} FacetScope
|
||||||
@@ -28,15 +60,95 @@ import { isAbsolute, resolve } from 'node:path'
|
|||||||
/**
|
/**
|
||||||
* Build the policy for one deployment.
|
* 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 {object} config
|
||||||
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
|
* @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.
|
* @returns {object} the `facetPolicy` service implementation.
|
||||||
*/
|
*/
|
||||||
export function createFacetPolicy(config) {
|
export function createFacetPolicy(config) {
|
||||||
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
|
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
|
||||||
const grants = new Map()
|
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.
|
* 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
|
* 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".
|
* 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.
|
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
|
||||||
* @returns {FacetScope|undefined} the scope.
|
* @returns {FacetScope|undefined} the scope.
|
||||||
*/
|
*/
|
||||||
function scopeFor(agent) {
|
function scopeFor(agent) {
|
||||||
const cwd = agent?.session?.header?.cwd
|
const cwd = agent?.session?.header?.cwd
|
||||||
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
|
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
|
||||||
const root = resolve(cwd)
|
const sessionCwd = resolve(cwd)
|
||||||
return { cwd: root, root, extras: extrasFor(agent, root) }
|
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
|
* Every path in scope beyond the root: the deployment seed, then what the user
|
||||||
* session's, deduplicated so the prompt does not repeat itself.
|
* 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 {{session?: {id?: unknown}}|undefined} agent - the calling agent.
|
||||||
* @param {string} cwd - the resolved session directory.
|
* @param {string} cwd - the resolved session directory.
|
||||||
* @returns {string[]} absolute paths.
|
* @returns {string[]} absolute paths.
|
||||||
*/
|
*/
|
||||||
function extrasFor(agent, cwd) {
|
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 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)]
|
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 {string} sessionId - the session being widened.
|
||||||
* @param {readonly string[]} paths - raw paths, resolved against `cwd`.
|
* @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.
|
* @param {string} sessionId - the session to clear.
|
||||||
* @returns {string[]} always empty.
|
* @returns {string[]} always empty.
|
||||||
*/
|
*/
|
||||||
function reset(sessionId) {
|
function reset(sessionId) {
|
||||||
grants.delete(sessionId)
|
grants.delete(sessionId)
|
||||||
|
narrowed.delete(sessionId)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,15 +287,27 @@ export function createFacetPolicy(config) {
|
|||||||
/** Drop a finished session's grants. Called on `session/disposed`. */
|
/** Drop a finished session's grants. Called on `session/disposed`. */
|
||||||
function forget(sessionId) {
|
function forget(sessionId) {
|
||||||
grants.delete(sessionId)
|
grants.delete(sessionId)
|
||||||
|
narrowed.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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. */
|
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
|
||||||
sectionOrder: config.sectionOrder ?? 100,
|
sectionOrder: configValue(config.sectionOrder) ?? 100,
|
||||||
contextOrder: config.contextOrder ?? 105,
|
contextOrder: configValue(config.contextOrder) ?? 105,
|
||||||
standing: [...standing],
|
/** 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,
|
scopeFor,
|
||||||
|
rootFor,
|
||||||
|
narrowTo,
|
||||||
|
narrowFor,
|
||||||
grant,
|
grant,
|
||||||
revoke,
|
revoke,
|
||||||
reset,
|
reset,
|
||||||
@@ -146,3 +324,13 @@ export function createFacetPolicy(config) {
|
|||||||
function isPath(value) {
|
function isPath(value) {
|
||||||
return typeof value === 'string' && value.trim() !== ''
|
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
@@ -118,25 +118,24 @@ export function isWideningCall(toolName, args) {
|
|||||||
* treatment the sandbox policy and approval policy already get, for the same
|
* treatment the sandbox policy and approval policy already get, for the same
|
||||||
* reason.
|
* reason.
|
||||||
*
|
*
|
||||||
* @param {object} input
|
* It takes no configuration, and that is deliberate rather than an oversight.
|
||||||
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
|
* A system-prompt section is a plain string registered once; there is no
|
||||||
* @returns {string} prompt section text, or `''` to contribute nothing.
|
* 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) {
|
export function facetRuleText() {
|
||||||
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.'
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'# Facet scope',
|
'# 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.',
|
'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.
|
* 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
|
* Kept short because it is re-sent as part of the conversation whenever it
|
||||||
* whenever it changes, and a scope statement that costs more than the scope is
|
* changes, and a scope statement that costs more than the scope is worth is a
|
||||||
* worth is a scope that does not pay for itself.
|
* 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 {object} input
|
||||||
* @param {string} input.cwd - the session working directory.
|
* @param {string} input.cwd - the session working directory.
|
||||||
* @param {string|undefined} input.root - the facet root, or undefined when unscoped.
|
* @param {string|undefined} input.root - the facet root, or undefined when unscoped.
|
||||||
* @param {readonly string[]} input.extras - absolute granted paths, outside the root.
|
* @param {readonly string[]} input.extras - absolute granted paths, outside the root.
|
||||||
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
|
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
|
||||||
|
* @param {boolean} [input.canAsk] - whether an approval request can reach the user. False means every ask is refused before anyone is consulted.
|
||||||
* @returns {string} context text, or `''` to contribute nothing.
|
* @returns {string} context text, or `''` to contribute nothing.
|
||||||
*/
|
*/
|
||||||
export function facetContextText(input) {
|
export function facetContextText(input) {
|
||||||
const { cwd, root, extras = [], mode } = input
|
const { cwd, root, extras = [], mode, canAsk = true } = input
|
||||||
if (mode === 'off' || typeof root !== 'string' || root === '') return ''
|
if (mode === 'off' || typeof root !== 'string' || root === '') return ''
|
||||||
|
|
||||||
|
// Stated in terms of what the model should *do*, because the same sentence is
|
||||||
|
// 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
|
// 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
|
// 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
|
// 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.
|
// prompt needs to see which folder they are being asked about.
|
||||||
const lines = [`facet: ${root}`]
|
const lines = [`facet: ${root}`]
|
||||||
if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`)
|
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')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dsh-facet",
|
"name": "dsh-facet",
|
||||||
"version": "0.1.1",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
|
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
"types": "./lib/types/guard.d.ts",
|
"types": "./lib/types/guard.d.ts",
|
||||||
"default": "./lib/guard.js"
|
"default": "./lib/guard.js"
|
||||||
},
|
},
|
||||||
|
"./client": {
|
||||||
|
"default": "./client/client.js"
|
||||||
|
},
|
||||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||||
"./presets/facet.patch.yml": "./presets/facet.patch.yml",
|
"./presets/facet.patch.yml": "./presets/facet.patch.yml",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
"lib",
|
"lib",
|
||||||
|
"client",
|
||||||
"presets",
|
"presets",
|
||||||
"cordis.patch.yml",
|
"cordis.patch.yml",
|
||||||
"README.md"
|
"README.md"
|
||||||
@@ -40,6 +44,15 @@
|
|||||||
"./cordis.patch.yml",
|
"./cordis.patch.yml",
|
||||||
"./presets/facet.patch.yml"
|
"./presets/facet.patch.yml"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-settings",
|
||||||
|
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
||||||
|
"@deepseek-ai/dsh-client-ui-conversation",
|
||||||
|
"@deepseek-ai/dsh-client-ui-workspace"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -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
@@ -18,6 +18,15 @@ import { after, before, test } from 'node:test'
|
|||||||
|
|
||||||
import { apply as guard } from '../lib/guard.js'
|
import { apply as guard } from '../lib/guard.js'
|
||||||
import { createFacetPolicy } from '../lib/policy.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. */
|
/** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */
|
||||||
const fsLikeLocal = {
|
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 } = {}) {
|
* Capture what `apply` registered, so the test can drive it.
|
||||||
const policy = createFacetPolicy({ mode, extraRoots })
|
*
|
||||||
|
* `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 captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined }
|
||||||
const ctx = {
|
const ctx = {
|
||||||
facetPolicy: policy,
|
facetPolicy: policy,
|
||||||
fs: fsLikeLocal,
|
fs: fsLikeLocal,
|
||||||
|
get: (name) =>
|
||||||
|
name === 'approval' && approval !== undefined ? { effectivePolicy: () => approval } : undefined,
|
||||||
systemPrompt: {
|
systemPrompt: {
|
||||||
section: (value) => {
|
section: (value) => {
|
||||||
captured.section = value
|
captured.section = value
|
||||||
@@ -89,6 +107,7 @@ before(async () => {
|
|||||||
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
|
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
|
||||||
facet = join(root, 'src', 'controllers', 'room')
|
facet = join(root, 'src', 'controllers', 'room')
|
||||||
await mkdir(facet, { recursive: true })
|
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', 'controllers', 'other'), { recursive: true })
|
||||||
await mkdir(join(root, 'src', 'shared'), { recursive: true })
|
await mkdir(join(root, 'src', 'shared'), { recursive: true })
|
||||||
await writeFile(join(facet, 'index.ts'), 'export {}\n')
|
await writeFile(join(facet, 'index.ts'), 'export {}\n')
|
||||||
@@ -217,6 +236,25 @@ test('off mode registers nothing', () => {
|
|||||||
assert.equal(captured.tool, undefined)
|
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 () => {
|
test('the guard adds to a decision, never overrides one', async () => {
|
||||||
const { captured } = harness()
|
const { captured } = harness()
|
||||||
const denied = await dispatch(captured, {
|
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), '')
|
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 () => {
|
test('the facet tool reports the scope', async () => {
|
||||||
const { captured } = harness()
|
const { captured } = harness()
|
||||||
const value = await captured.tool.execute({ action: 'status' }, { agent: agent() })
|
const value = await captured.tool.execute({ action: 'status' }, { agent: agent() })
|
||||||
@@ -276,6 +349,60 @@ test('the facet tool grants, narrows and clears', async () => {
|
|||||||
assert.deepEqual(policy.grantsFor('session-1'), [])
|
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 () => {
|
test('the facet tool refuses a call it cannot act on', async () => {
|
||||||
const { captured } = harness()
|
const { captured } = harness()
|
||||||
const exec = { agent: agent() }
|
const exec = { agent: agent() }
|
||||||
|
|||||||
+61
-2
@@ -10,6 +10,18 @@ import assert from 'node:assert/strict'
|
|||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
|
|
||||||
import { apply, Config } from '../index.js'
|
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. */
|
/** The smallest context this row touches. */
|
||||||
function harness() {
|
function harness() {
|
||||||
@@ -28,12 +40,59 @@ function harness() {
|
|||||||
|
|
||||||
test('the schema fills every default, so a bare row is a working row', () => {
|
test('the schema fills every default, so a bare row is a working row', () => {
|
||||||
const config = Config({})
|
const config = Config({})
|
||||||
assert.equal(config.mode, 'ask')
|
assert.equal(configValue(config.mode), 'ask')
|
||||||
assert.deepEqual(config.extraRoots, [])
|
assert.deepEqual(configValue(config.extraRoots), [])
|
||||||
assert.equal(config.sectionOrder, 100)
|
assert.equal(config.sectionOrder, 100)
|
||||||
assert.equal(config.contextOrder, 105)
|
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', () => {
|
test('a bad mode fails loudly at load rather than at the first file call', () => {
|
||||||
assert.throws(() => Config({ mode: 'maybe' }))
|
assert.throws(() => Config({ mode: 'maybe' }))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }).sectionOrder, 7)
|
||||||
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8)
|
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
@@ -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', () => {
|
test('facetRuleText states the rule and never the binding', () => {
|
||||||
const ask = facetRuleText({ mode: 'ask' })
|
const rule = facetRuleText()
|
||||||
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 deny = facetRuleText({ mode: 'deny' })
|
// Mode-neutral by construction. A prompt section is registered once as a plain
|
||||||
assert.match(deny, /A call outside it is refused/)
|
// 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
|
// The rule must not name a path: a changing system prompt invalidates the
|
||||||
// prompt cache for the whole conversation.
|
// 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' }), '')
|
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('facetContextText carries the binding that does change', () => {
|
test('facetContextText carries the binding that does change', () => {
|
||||||
|
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
|
||||||
assert.equal(
|
assert.equal(
|
||||||
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
|
ask,
|
||||||
'facet: /w/src/room\noutside the facet: ask the user',
|
'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(
|
assert.equal(
|
||||||
facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }),
|
deny,
|
||||||
'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused',
|
'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.
|
// 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: 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', () => {
|
test('displayPath prefers the shortest unambiguous form', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user