10 Commits
Author SHA1 Message Date
gitea-actions a40040ec17 Release v0.3.1 [skip release] [skip actions] 2026-09-27 06:44:49 +00:00
NotExpectedYet 8ab3299694 fix: a delegated session inherits the facet it was spawned under
Release / release (push) Successful in 6s
The user's declaration is keyed by session id, and a session spawned or forked
from a scoped one has none of its own — so its facet was its own working
directory, one level above its parent's. Delegation was therefore a way around
the scope rather than work inside it: the parent could not read the file itself
and could have a subagent read it.

A session now inherits the root resolved for the session named by its
`parentSession` header, which the harness already records durably for both a
spawned child and a fork. The inherited root is the parent's *effective* root, so
it already includes any narrowing the parent made for itself: delegating is not
a way back out of a self-imposed focus either. Because each session records its
own root as it resolves a scope, and a scope is resolved for every model request
and every tool call, inheritance holds at any depth of delegation rather than for
one hop.

Two boundaries are deliberate. A declaration the user made *for the child* is a
more specific statement about that session and stands — inheritance only answers
the case where they made none. And a parent's recorded root outlives its session
while any child that inherited it is still running, because disposing a parent
must not quietly widen a subagent that is still working.
2026-09-27 07:43:03 +01:00
NotExpectedYet a9a25490db fix: judge a search that names no path against the directory it would use
The guard's rule was that a call naming no path is not judged, because a tool
that names no path cannot leave the facet. That is true of a tool with no path
argument and false of three that have one with a default: `grep` and `glob`
document theirs as "Defaults to the session workspace", and `recall` falls back
to a directory its own marker search picks. On any session whose facet is
narrower than its directory — every session whose focus was set from the panel
rather than by starting the conversation in the folder — a bare `grep` searches
outside the facet and returns what it finds.

An absent argument is not an absent path; it is the default one. The three tools
are now judged against the directory they would have searched, through the same
containment test that decides every other call, and the refusal is its own
sentence rather than the "that path is outside" one: the path at issue is one the
model never wrote, so the remedy is a second argument, not a grant.

A tool with no path argument at all is still left alone. `bash` is still the
documented way out, and the first deployment running this plugin confirmed it is
a real one: an agent scoped to one module used a shell command to parse a config
file outside its facet, having been told in the same prompt not to. That is what
this plugin is for, and what it cannot do is stated in the README rather than
implied.
2026-09-27 07:42:54 +01:00
NotExpectedYet b0902017fd fix: name the allowed-paths action for what it adds, a path or a file
"Add folder or path" promised one thing and offered another: the listing is
folders only, because the Host's browse primitive has no file rows, while the
extras legitimately name files — `../../CONTROLLER.md` is the example the panel
itself gives.

The action is "Add path or file", its field is "Path or file", and the hint says
which of the two the list can show and how the other one is added. Nothing about
the interaction moved; the copy was the part that was wrong.
2026-09-27 07:31:50 +01:00
gitea-actions 6b92656ed2 Release v0.3.0 [skip release] [skip actions] 2026-09-27 06:08:22 +00:00
NotExpectedYet 5d06829118 feat: pick the focus and the allowed paths from a directory listing
Release / release (push) Successful in 6s
Both scope controls were free-text boxes, and the vocabulary they asked the user
for is paths relative to the session folder. "Which folder is `../shared`" is the
one question a text box cannot answer, so the panel now shows the Host's own
directory: the `uiWorkspace.listDirectory` primitive the workspace picker's
browser drives, one level of child folders, a breadcrumb trail, and a
hidden-entries toggle. The path field stays editable inside the picker, because
the Host lists folders and an extra may legitimately name a file.

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

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

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

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

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

Also says outright that committing is local, because pushing to `master` is what
releases and that is a different decision.
2026-09-27 07:05:43 +01:00
gitea-actions ef8829cb56 Release v0.2.0 [skip release] [skip actions] 2026-09-27 05:18:46 +00:00
NotExpectedYet 266e90aa2d feat: set a conversation's scope from the composer, where the agent cannot reach
Release / release (push) Successful in 6s
The per-session scope the host half now reads had no writer. This is it: a
crosshair on the composer tool row, present only in sessions running the facet
preset, opening a panel that sets the focus and the paths allowed outside it.

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

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

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

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

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

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

The binding now names the context panel as the way to widen, because it is: in
a deployment with no approval channel, `facet action=add` cannot be granted at
all.
2026-09-27 06:10:53 +01:00
13 changed files with 2390 additions and 102 deletions

No files matched your search

+18
View File
@@ -21,6 +21,24 @@ release notes. Write it for that reader:
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
subject line is read; the body is not.
## Commit as the work lands
Commit each step as soon as it is finished rather than leaving it in the tree
until the end. Two reasons, and the second is the one that bites:
- An uncommitted change has no release note at all. The subject is the sentence a
user reads, so work that never becomes a commit never becomes a sentence.
- A tree carrying four unrelated changes cannot be split into four honest
subjects after the fact — the reasoning behind each one is only in the
transcript by then, and the range picks its bump from whatever subjects it
ends up with. One `feat:` batched in with a handful of fixes is a minor bump
for all of them.
Keep a subject to one change and the version follows from it.
A commit here is local. Pushing to `master` is what releases, so it stays a
separate, deliberate step.
## The release job owns the version and the changelog
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
+28
View File
@@ -2,6 +2,34 @@
Every release, newest first, generated from the commits in each release.
## v0.3.1 — 2026-09-27
### Fixed
- a delegated session inherits the facet it was spawned under
- judge a search that names no path against the directory it would use
- name the allowed-paths action for what it adds, a path or a file
## 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
+118 -25
View File
@@ -48,6 +48,15 @@ wrong, and an agent that cannot reach a shared type does not stop needing it.
So an out-of-scope file call is put to you, and saying yes records the grant for
the rest of the conversation.
**A tool that names no path is judged on the path it would use.** `grep`, `glob`
and `recall` all default to a directory when the model omits one — the session
directory for the first two, a marker-chosen one for the third — and on a session
whose facet is narrower than its directory, that default reads outside the facet.
An absent argument is not an absent path, so those three are judged against the
default they would have searched, and the refusal says which argument to add.
`lib/scope.js` holds the list; a tool with no path argument at all is still left
alone, because it has no default that could leave.
### The `facet` tool
| action | effect |
@@ -103,23 +112,22 @@ first turn has run, so choose it when you start the conversation.
### Configure it in your profile, not in the package
The host row's config is where this deployment's facts belong — the profile's own
`cordis.patch.yml`:
The host row's config is where this *deployment's* facts belong — the profile's
own `cordis.patch.yml`:
```yaml
- id: facet-policy
name: dsh-facet
config:
mode: ask # ask | deny | off
extraRoots: # always in scope, in every session
- ../../shared # relative to each session's own directory
extraRoots: [] # optional: a seed of paths always in scope, every session
```
Both fields are also on the plugin's card under **Plugins** in the Web UI, which
is the same layer reached a different way: a settings write rewrites this entry in
the profile patch rather than storing anything new. An edit there is live — the
guard reads `mode` and `extraRoots` on every call, so a change decides the next
file call without a restart.
Both are also on the plugin's card under **Plugins** in the Web UI, which is the
same layer reached a different way: a settings write rewrites this entry in the
profile patch rather than storing anything new. An edit there is live — the guard
reads `mode` and `extraRoots` on every call, so a change decides the next file
call without a restart.
The one exception is `off`. Choosing it while a session is running stops the guard
immediately and empties the binding on the next request, because the binding is a
@@ -132,11 +140,58 @@ patch. `ask` and `deny` swap freely in both directions.
place the rule and the binding among the other prompt sections, which is decided
once at load, and no section is re-ordered without a remount.
`extraRoots` is for code a module legitimately always depends on. Anything you
grant by hand is per-conversation and is never persisted — "this session may
also see `src/shared`" is a statement about one conversation, and writing it down
would quietly widen every future one, which is the failure this plugin exists to
prevent.
### Per-session scope, and who gets to decide it
Scope has two layers, and the split is the point:
| layer | what it is | who can write it |
|---|---|---|
| **the user's declaration** | `scopes[<session id>]` in this row's config — a `focus` and a list of `extras` | only the context panel, through the settings service |
| **the agent's** | a narrowing `focus` and any session grants, held in memory | the agent, and only ever to *reduce* |
The effective root is the agent's narrowing while it still sits inside your root,
and your root otherwise. So an agent that goes wrong cannot widen its own scope,
and `facet action=reset` returns it to what you declared rather than to the
session directory — landing on the directory would have made `reset` a widening.
**A session spawned or forked from a scoped one inherits its root.** Your
declaration is keyed by session id, and a subagent or a fork has none of its own,
so without this its facet would be its own working directory — one level up, and
a way to delegate around the scope rather than work inside it. The inherited root
already includes the parent's own narrowing, so delegating is not a way back out
of a focus the agent set for itself either, and it is recorded per level, so it
holds at any depth of delegation. It applies only where you made no statement:
a declaration for the child session is the more specific answer and stands.
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 — which is why that action reads **Add path or file** and its field **Path
or file**, rather than promising a folder the list would then fail to show.
Because the panel owns widening, `facet action=add` is the *other* path and the
narrower one: it is gated on an approval channel, and a deployment with none — the
`danger-full-access` permission preset sets `approval: never` — refuses it before
anyone is asked. In that deployment the panel is the only way to widen, which is
exactly what the binding tells the agent.
`extraRoots` is only a seed now. Before per-session scope it was the *only* way to
widen anything, which meant "this conversation needs the entity folder" had to be
written as a fact about every conversation in every workspace. It is for paths a
deployment genuinely always wants, and it is empty by default.
## The compaction row
@@ -171,7 +226,10 @@ Honest boundaries, because each one is a decision rather than an oversight:
- **`bash` is not guarded.** A shell command cannot be judged reliably from
argv, so the facet is enforced on the file tools and `bash` remains a
legitimate way out. The prompt tells the model this rather than pretending
otherwise.
otherwise. It is not a theoretical hole: the first deployment to run this had
an agent scoped to one module use a shell command to parse a config file
outside its facet, having been told in the same prompt not to. A prompt is not
a guard, which is what the rest of this plugin is for.
- **Tools that name a path in a shape the table cannot read are not guessed at** —
including MCP tools. See `PATH_ARGUMENTS` in `lib/scope.js`; it is a list of
tools whose path argument is a plain top-level string.
@@ -188,24 +246,31 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified:
- 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and
- 88 tests (`npm test`) over the scope algebra, the policy state, the guard, and
the browser half — including that containment is decided on canonical targets
(a symlink inside the facet pointing outside it is refused), that a prefix is
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
call never makes the guard throw, that a live `mode` edit decides the next call
without the guard being remounted, and that every `facet` result satisfies the
tool's own declared output schema.
without the guard being remounted, that an agent narrowing the user has since
narrowed past is dropped rather than honoured, that `reset` lands on the
declared scope rather than the session directory, that a search naming no path
is refused when the directory it defaults to is outside the facet, that a
delegated session inherits its parent's root at any depth and is not a way back
out of a self-narrowing, and that every `facet` result satisfies the tool's own
declared output schema.
- The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
the `facet-guard` row and the compaction config, with `preset-standard`
untouched. The optional `standard-compaction` overlay was checked the same way.
**Not yet verified: a live session.** The plugin has not been loaded by a running
harness, because that needs the bundle installed. The first thing to check after
installing is that `preset-facet` is not `broken` — a preset whose child row
fails to resolve still appears on the roster, with its diagnostic, and cannot
compose a session:
**Verified live.** The bundle is installed in a `web` profile and composing
sessions: the runtime binding is in the running session's context, the crosshair
panel writes the declaration, and the guard decides the calls it names. That is
also where the `bash` boundary above was confirmed the hard way. The first thing
to check after any change is still that `preset-facet` is not `broken` — a preset
whose child row fails to resolve still appears on the roster, with its
diagnostic, and cannot compose a session:
```sh
dsh --profile web --dump-config | grep -A 3 preset-facet
@@ -264,13 +329,13 @@ it.
```
index.js host row: the policy, published as ctx.facetPolicy
client/client.js browser half: the settings card on the Plugins page
client/client.js browser half: the settings card, and the context panel
lib/policy.js the stateful half — scope resolution, grants
lib/scope.js the pure half — argument extraction, text, reasons
lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard`
test/ 53 tests
test/ 88 tests
```
`client/client.js` is a browser half rather than a module the host imports, and
@@ -287,6 +352,34 @@ entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
happens to name them identically, so binding the form to the wrong one produces
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
The same file carries the context panel, registered into
`conversation.input.left` — the composer tool row — and gated on the session's own
`agentPreset` projection, so it appears only where the facet is actually in force.
That gate is why `scopes` is `volatile()` in the host schema: `settings.mutate`
refuses a path that is not under a volatile node, and refusing is exactly right —
the panel is the only writer, and a plugin that could not serve the namespace
would be a control that silently stored nothing.
The listing reaches the panel through `ctx.get('uiWorkspace')` rather than through
an injection, and the injection's `available()` is asked on every render. Both are
deliberate: `ctx.inject` would make the whole context button conditional on a
service the plugin does not own, and the panel is still the only way to declare
scope in a deployment with no directory browser — it would simply have no listing
to offer, which is what it says instead. The service is resolved at the call
because the renderer memoizes a registration's inject result, so a boolean read
there would freeze at whatever the first render saw.
The two dialogs are wider than the primitive's 380px card, which is fixed in the
primitives' own stylesheet. `client/client.js` installs one small stylesheet of
its own — the widths and a row hover — and passes each card a class through
`Modal`'s `className`. Every selector in it doubles the class it names, so it
outranks the single hashed class the primitive applies whatever order the two
stylesheets land in; there is no `!important` and nothing reaching into a class
name this bundle does not own. The panel's body is a two-column grid that
`auto-fit` collapses to one on a narrow card, and each dialog scrolls a bounded
body rather than growing past the viewport — the panel used to be a tall single
column of prose on a 380px card, which is what it looked like.
## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+985 -3
View File
File diff suppressed because it is too large. Load diff
+51 -12
View File
@@ -33,20 +33,25 @@ export const name = 'dsh-facet'
/**
* Every configuration field this plugin exposes.
*
* `mode` and `extraRoots` are marked `volatile()`, and that marker is doing two
* jobs rather than one. It is what puts them on the settings page at all — the
* framework's form projection keeps only fields under a volatile ancestor, and
* drops an entry with no such field from `settings.describe` entirely, so
* without it this row has no card. It is also what makes an edit *live*: a
* volatile field is handed to {@link apply} as a reference rather than a value,
* and the framework commits an edit into that reference without re-applying the
* plugin.
* `mode`, `extraRoots` and `scopes` are marked `volatile()`, and that marker is
* doing two jobs rather than one. It is what puts them on the settings page at
* all — the framework's form projection keeps only fields under a volatile
* ancestor, and drops an entry with no such field from `settings.describe`
* entirely, so without it this row has no card. It is also what makes an edit
* *live*: a volatile field is handed to {@link apply} as a reference rather than
* a value, and the framework commits an edit into that reference without
* re-applying the plugin.
*
* The second job is why every reader of these two fields goes through
* The second job is why every reader of these fields goes through
* `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin
* that takes the reference for a string gets a settings card that appears to
* work and changes nothing.
*
* `scopes` is volatile for a third reason: it is the only field the browser
* context panel writes, and `settings.mutate` refuses a path that is not under a
* volatile node. That is the whole mechanism by which the panel — and not the
* agent — decides what one conversation may see.
*
* `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place
* the rule and the binding among the other prompt sections, which is settled
* once at load; making them volatile would put two knobs nobody should touch on
@@ -61,12 +66,46 @@ export const Config = Schema.object({
mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(),
/**
* Paths always in scope, for code a module legitimately depends on — shared
* types, a common test harness. Relative paths resolve against each session's
* own directory, so the same list is meaningful in every workspace.
* A deployment-wide seed of paths always in scope, for code a module
* legitimately depends on — shared types, a common test harness.
*
* Deliberately only a seed. Before per-session scope existed this was the only
* way to widen anything, which meant "this one conversation needs the entity
* folder" had to be written as a fact about every conversation in every
* workspace. Per-session scope belongs in {@link Config.scopes}; this is for
* the paths a deployment genuinely always wants, and it is empty by default.
*
* Relative paths resolve against each session's own directory, so the same
* list is meaningful in every workspace.
*/
extraRoots: Schema.array(String).default([]).volatile(),
/**
* Per-session scope — the user's declaration for one conversation, keyed by
* session id.
*
* Written only by the browser context panel, through the settings service, and
* never by the agent: this plugin exposes no write path to it, and a facet
* session carries no settings tool. `focus` narrows the session to a folder;
* `extras` are paths the conversation may read outside it. Both are relative
* to the session's own directory unless absolute, so a declaration keeps its
* meaning wherever the workspace lives.
*
* Entries outlive their sessions, which is deliberate — reopening a chat
* should give back the scope it had — and costs a little growth in the
* profile patch that the panel can tidy.
*/
scopes: Schema.dict(
Schema.object({
/** The session's facet root, relative to the session directory or absolute. Empty means the session directory itself. */
focus: Schema.string(),
/** Paths this session may also read, outside the root. */
extras: Schema.array(String).default([]),
}),
)
.default({})
.volatile(),
/** Placement of the invariant rule in the system prompt. */
sectionOrder: Schema.number().default(100),
+79 -12
View File
@@ -37,6 +37,8 @@ import {
FACET_TOOL,
facetContextText,
facetRuleText,
IMPLICIT_SESSION_PATHS,
implicitSearchReason,
isWideningCall,
outsideReason,
pathArgumentsFor,
@@ -181,7 +183,20 @@ export function apply(ctx, config) {
if (scope === undefined) return undefined
const paths = pathArgumentsFor(exec.name, exec.arguments)
if (paths.length === 0) return undefined
if (paths.length === 0) {
// A tool that names no path is judged against the path it will actually
// use. For most of them that is nothing — a tool with no path argument
// cannot leave the facet. For the ones whose own default is a directory it
// is the session directory, which a narrowed facet does not contain, and
// "no opinion" there would hand the model a read the guard exists to
// refuse. The containment test is the same one; only the path is ours
// rather than the model's.
if (!IMPLICIT_SESSION_PATHS.includes(exec.name)) return undefined
const verdict = await inside(scope, scope.cwd, exec.signal)
if (verdict.inside) return undefined
const kind = mode === 'deny' ? 'deny' : 'ask'
return { kind, reason: implicitSearchReason({ cwd: scope.cwd, root: scope.root }) }
}
for (const raw of paths) {
const verdict = await inside(scope, raw, exec.signal)
@@ -246,13 +261,18 @@ function requestedPaths(args) {
}
/**
* The `facet` tool: read the scope, or ask to widen it.
* The `facet` tool: read the scope, narrow it, or ask to widen it.
*
* `add` does not enforce anything itself — the pre-execute listener has already
* put the question to the user, and the body only runs if they said yes. That
* split is deliberate: the approval is a property of the *call*, so it holds
* even if this tool is later replaced or dispatched from `run_code`.
*
* `focus` is the one action this tool performs without asking, and it is the
* only one that can only ever *reduce* the scope. That asymmetry is the whole
* design: an agent may decide a conversation is about one folder, and may not
* decide it is about more.
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
* @param {object} policy - the `facetPolicy` service.
*/
@@ -260,21 +280,23 @@ function facetTool(ctx, policy) {
return defineTool({
name: FACET_TOOL,
description:
'Read this session\u2019s facet scope, or ask the user to grant an extra path. A facet is the folder the ' +
'conversation was started in; the session is scoped to it. Use `status` to see the current scope. Use `add` ' +
'only when you actually need a file outside the facet — it asks the user for permission, and the grant lasts ' +
'for the rest of the conversation. Never call `add` for a path you merely expect to need.',
'Read this session\u2019s facet scope, narrow it to one folder, or ask the user for an extra path. A facet is ' +
'the folder the conversation is scoped to; `status` reports it. Use `focus` when the work has settled on one ' +
'folder inside the facet — narrowing is always allowed and takes effect at once. Use `add` only when you ' +
'actually need a file outside the facet: it asks the user for permission, and the grant lasts for the rest of ' +
'the conversation. Never call `add` for a path you merely expect to need. Where the deployment has no approval ' +
'channel, `add` cannot be granted at all and the user widens the scope from the facet panel in their browser.',
parameters: {
action: {
type: 'string',
required: true,
description: 'One of: status, add, remove, reset.',
description: 'One of: status, focus, add, remove, reset.',
},
paths: {
type: 'array',
items: { type: 'string' },
description:
'Paths to grant, relative to the session directory or absolute. Required for add and remove; ignored otherwise.',
'Paths, relative to the session directory or absolute. Required for add and remove; for focus, exactly one folder inside the current facet.',
},
},
output: {
@@ -307,13 +329,16 @@ function facetTool(ctx, policy) {
const problem =
(action === 'add' || action === 'remove') && requested.length === 0
? `facet: action "${action}" needs \`paths\``
: !['status', 'add', 'remove', 'reset'].includes(action)
? `facet: unknown action "${action}"; expected status, add, remove or reset`
: undefined
: action === 'focus' && requested.length !== 1
? 'facet: action "focus" needs exactly one folder'
: !['status', 'focus', 'add', 'remove', 'reset'].includes(action)
? `facet: unknown action "${action}"; expected status, focus, add, remove or reset`
: undefined
if (problem !== undefined) throw new Error(problem)
const before = policy.grantsFor(sessionId)
if (action === 'add') policy.grant(sessionId, requested, cwd)
if (action === 'focus') policy.narrowTo(sessionId, await narrowing(ctx, policy, exec, requested[0]))
else if (action === 'add') policy.grant(sessionId, requested, cwd)
else if (action === 'remove') policy.revoke(sessionId, requested, cwd)
else if (action === 'reset') policy.reset(sessionId)
@@ -340,6 +365,48 @@ function facetTool(ctx, policy) {
})
}
/**
* Resolve one `focus` request to the canonical root it asks for.
*
* Refuses anything the scope already in force does not contain, because that
* would be a widening by another name — and widening is the user's decision, not
* the agent's. The containment test is the filesystem provider's, on canonical
* targets, for the same reason the guard uses it: a symlink inside the facet
* that points outside it must not become a way through.
*
* The canonical key is what gets recorded, so a later `scopeFor` compares like
* with like. Where the user declared a root through a symlink, the two spellings
* may not share a prefix; the narrowing is then dropped rather than trusted,
* which loses the narrowing and never the boundary.
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context, holding `fs`.
* @param {object} policy - the `facetPolicy` service.
* @param {import('@deepseek-ai/dsh-tools').ToolExecution} exec - the running call.
* @param {string} raw - the folder the model named.
* @returns {Promise<string>} the absolute root to narrow to.
*/
async function narrowing(ctx, policy, exec, raw) {
const scope = policy.scopeFor(exec.agent)
if (scope === undefined) throw new Error('facet: this session has no working directory, so there is nothing to narrow')
let target
let boundary
try {
target = await ctx.fs.resolve(raw, { cwd: scope.cwd, signal: exec.signal })
boundary = await ctx.fs.resolve(scope.root, { cwd: scope.cwd, signal: exec.signal })
} catch {
throw new Error(`facet: cannot resolve "${raw}", so the facet cannot be narrowed to it`)
}
if (target.targetKey !== boundary.targetKey && !ctx.fs.contains(boundary, target)) {
throw new Error(
`facet: "${raw}" is outside the current facet (${scope.root}). Narrowing cannot leave the scope already in force; ` +
'ask the user to widen it from the facet panel if the work really has moved.',
)
}
return target.targetKey
}
/** Model-facing text for one `facet` result. Pure. */
function renderFacet(value) {
const lines = [
+231 -14
View File
@@ -16,7 +16,7 @@
* exists to prevent.
*/
import { isAbsolute, resolve } from 'node:path'
import { isAbsolute, resolve, sep } from 'node:path'
/**
* The symbol cosmokit uses to mark a volatile config reference.
@@ -63,18 +63,124 @@ export function configValue(value) {
* `config` is the *resolved* composition config, so its volatile fields are
* references rather than values; every read below goes through
* {@link configValue} and happens per use. Holding the config and reading it lazily
* is what makes the settings card honest: `mode` and `extraRoots` are edited live
* in the Web UI, and a policy that snapshotted them here would keep answering
* with the values it was loaded with.
* is what makes the settings card honest: `mode`, `extraRoots` and `scopes` are
* edited live in the Web UI, and a policy that snapshotted them here would keep
* answering with the values it was loaded with.
*
* ## Two layers of scope, and only one of them the agent can move
*
* The session's scope is declared in two places on purpose, because the two
* audiences have opposite authority:
*
* * the **user layer** is `config.scopes[sessionId]` — a `focus` and a list of
* `extras`, written only from the browser context panel through the settings
* service. The agent has no write path to it: nothing in this plugin exposes
* one, and a facet session carries no settings tool.
* * the **agent layer** is in memory — a narrowing `focus` override and the
* grants it asked for. Both can only ever *reduce* what the user layer
* allows, so an agent that goes wrong cannot widen its own scope.
*
* The effective root is the agent's narrowing while it still sits inside the
* user's root, and the user's root otherwise. That ordering matters when the
* user narrows after the agent has: the stale agent focus is dropped rather than
* honoured, because honouring it would put the session outside the scope the
* user had just declared.
*
* @param {object} config
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
* @param {readonly string[]} [config.extraRoots] - a deployment-wide seed of standing grants.
* @param {Record<string, {focus?: string, extras?: readonly string[]}>} [config.scopes] - per-session scope, keyed by session id.
* @returns {object} the `facetPolicy` service implementation.
*/
export function createFacetPolicy(config) {
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
const grants = new Map()
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
const narrowed = new Map()
/**
* The root resolved for each session, recorded as it is computed.
*
* A session spawned or forked from this one has no declaration of its own, so
* without this its facet would be its own working directory — wider than the
* conversation it came from, which is the one direction this whole plugin
* promises cannot happen. The recorded root already includes the parent's own
* narrowing, so a child is never wider than the parent is *now*, and the
* record's existence is what makes the inheritance work per level rather than
* for one hop.
*
* @type {Map<string, string>} session id → the root in force for it.
*/
const roots = new Map()
/**
* Child session id → the session it inherited from, for as long as both live.
*
* Kept so {@link forget} can tell a root nobody needs any more from one a
* delegated child is still answering to.
*
* @type {Map<string, string>}
*/
const spawnedFrom = 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 session this one was spawned or forked from, when the header says so.
*
* The harness's own durable lineage field, so the link survives a restart and
* a child whose parent is no longer running is still contained. `fork` records
* the session it was seeded from and a subagent its direct parent; both are
* one conversation's work continuing, which is what makes inheriting the right
* answer for either.
*
* @param {{session?: {header?: {parentSession?: unknown}}}|undefined} agent - the calling agent.
* @returns {string|undefined} the parent's session id.
*/
function parentOf(agent) {
const parent = agent?.session?.header?.parentSession
return typeof parent === 'string' && parent !== '' ? parent : 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.
*
* Inheritance is the same kind of test, and it is skipped when the user made a
* declaration of their own: that is their decision about *this* session, and
* the point of inheriting is to answer the case where they made none.
*
* @param {string|undefined} sessionId - the session.
* @param {string} sessionCwd - its resolved working directory.
* @param {string} [inherited] - the root of the session it came from, if known.
* @returns {string} the absolute facet root.
*/
function rootFor(sessionId, sessionCwd, inherited) {
const declared = declaredScope(sessionId)?.focus
const stated = typeof declared === 'string' && declared.trim() !== ''
const own = stated ? resolve(sessionCwd, declared) : sessionCwd
const base = !stated && inherited !== undefined && within(inherited, own) ? inherited : own
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
if (agentFocus !== undefined && within(agentFocus, base)) return agentFocus
return base
}
/**
* The deployment's standing grants, read fresh on every call.
@@ -97,31 +203,87 @@ export function createFacetPolicy(config) {
* something arbitrary: `undefined` here means the guard has no opinion, which
* is the only safe reading of "we do not know where this agent is".
*
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
* `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.
*
* Resolving a scope is also where a session's own root is *recorded*, which is
* what a session spawned from it later inherits. Every session resolves a
* scope before it can call anything — the binding is assembled for each model
* request, and the guard classifies each tool call — so a parent's root is
* always on the record by the time it can spawn or fork.
*
* @param {{session?: {header?: {cwd?: unknown, parentSession?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
* @returns {FacetScope|undefined} the scope.
*/
function scopeFor(agent) {
const cwd = agent?.session?.header?.cwd
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
const root = resolve(cwd)
return { cwd: root, root, extras: extrasFor(agent, root) }
const sessionCwd = resolve(cwd)
const sessionId = idOf(agent)
const parent = parentOf(agent)
if (sessionId !== undefined && parent !== undefined) spawnedFrom.set(sessionId, parent)
const root = rootFor(sessionId, sessionCwd, parent === undefined ? undefined : roots.get(parent))
if (sessionId !== undefined) roots.set(sessionId, root)
return {
cwd: sessionCwd,
root,
extras: extrasFor(agent, sessionCwd),
}
}
/**
* Every path in scope beyond the root: standing grants first, then this
* session's, deduplicated so the prompt does not repeat itself.
* Every path in scope beyond the root: the deployment seed, then what the user
* declared for this session, then what the agent was granted — deduplicated so
* the prompt does not repeat itself.
*
* All three are kept rather than layered, because they answer different
* questions. The seed is "this deployment always needs it", the declaration is
* "this conversation needs it", and a grant is "the user allowed this call".
* Removing one from the list when a narrower one exists would mean a user
* editing their panel could silently drop a path the deployment depends on.
*
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
* @param {string} cwd - the resolved session directory.
* @returns {string[]} absolute paths.
*/
function extrasFor(agent, cwd) {
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
const sessionId = idOf(agent)
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
const declared = declaredScope(sessionId)?.extras
const listed = Array.isArray(declared) ? declared.filter(isPath) : []
const all = [...standingRoots(), ...listed, ...own].map((path) =>
isAbsolute(path) ? resolve(path) : resolve(cwd, path),
)
return [...new Set(all)]
}
/**
* Narrow one session's facet to a path the caller has already authorised.
*
* The containment check is the caller's, and deliberately so: only the guard
* holds `ctx.fs`, and deciding containment on canonical targets is the whole
* reason a symlink inside the facet does not open it. This records the answer;
* {@link rootFor} re-checks that the user has not since narrowed past it.
*
* @param {string} sessionId - the session being narrowed.
* @param {string} absolute - the absolute root to narrow to.
* @returns {string} the recorded focus.
*/
function narrowTo(sessionId, absolute) {
narrowed.set(sessionId, absolute)
return absolute
}
/**
* @param {string} sessionId - the session to read.
* @returns {string|undefined} the agent's narrowing for it, if any.
*/
function narrowFor(sessionId) {
return narrowed.get(sessionId)
}
/**
* @param {string} sessionId - the session being widened.
* @param {readonly string[]} paths - raw paths, resolved against `cwd`.
@@ -154,11 +316,19 @@ export function createFacetPolicy(config) {
}
/**
* Clear one session's agent-side scope: its grants and any narrowing it asked
* for.
*
* Deliberately not a widening. The user's declaration is not touched, so this
* returns the session to what the user set up in the panel — which may be
* narrower than where the agent had got to, and can never be wider.
*
* @param {string} sessionId - the session to clear.
* @returns {string[]} always empty.
*/
function reset(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
return []
}
@@ -170,9 +340,22 @@ export function createFacetPolicy(config) {
return [...(grants.get(sessionId) ?? [])]
}
/** Drop a finished session's grants. Called on `session/disposed`. */
/**
* Drop a finished session's state. Called on `session/disposed`.
*
* The root is the one entry that is not simply dropped: a session spawned from
* this one may still be running, and it answers to the facet it was spawned
* under. So the record is kept while any live session still records this one
* as its parent, and released the moment none does. Without that, disposing a
* parent would quietly widen its children back to their own directory.
*
* @param {string} sessionId - the session being disposed.
*/
function forget(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
spawnedFrom.delete(sessionId)
if (![...spawnedFrom.values()].includes(sessionId)) roots.delete(sessionId)
}
return {
@@ -183,11 +366,16 @@ export function createFacetPolicy(config) {
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
sectionOrder: configValue(config.sectionOrder) ?? 100,
contextOrder: configValue(config.contextOrder) ?? 105,
/** The standing grants. A getter for the same reason as {@link mode}. */
/** The deployment-wide seed of standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
/** What the user declared for one session: their `focus` and `extras`. */
declaredFor: declaredScope,
scopeFor,
rootFor,
narrowTo,
narrowFor,
grant,
revoke,
reset,
@@ -204,3 +392,32 @@ export function createFacetPolicy(config) {
function isPath(value) {
return typeof value === 'string' && value.trim() !== ''
}
/**
* Whether one absolute path is the other, or sits inside it.
*
* A string test, deliberately, and the only one in this module. The guard's
* containment check — the one that decides whether a *call* may proceed — runs
* on canonical filesystem targets, because a symlink inside the facet must not
* open it. This one only compares two roots the plugin itself derived from the
* session directory, where there is nothing to resolve and a symlink would have
* had to be introduced by the declaration.
*
* @param {string} inner - the path that may be inside.
* @param {string} outer - the path that may contain it.
* @returns {boolean} whether it is inside.
*/
function within(inner, outer) {
if (inner === outer) return true
return inner.startsWith(outer.endsWith(sep) ? outer : `${outer}${sep}`)
}
/**
* 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
}
+53 -5
View File
@@ -54,6 +54,28 @@ export const PATH_ARGUMENTS = Object.freeze({
recall: ['path', 'focus'],
})
/**
* Tools that will search the session directory when the model names no path.
*
* The guard's rule is that a call naming no path is not judged, because a tool
* that names no path cannot leave the facet. That holds for a tool with no path
* argument at all, and fails for these three: their *own* default is the session
* workspace. `grep` and `glob` document both their path arguments as "Defaults to
* the session workspace", and `recall` falls back to a directory its own marker
* search picks — an ancestor of the facet whenever the facet carries no
* `CONTROLLER.md` of its own.
*
* So on any session whose facet is narrower than its directory — every session
* whose focus was set from the panel rather than by starting the conversation in
* the folder — a bare `grep` reads outside the facet. An absent argument is not
* an absent path; it is the default one, and that is the path the guard judges.
*
* `recall` is another package's tool and is named here because the coupling to
* its two path arguments already exists in {@link PATH_ARGUMENTS}: leaving it
* out would keep a read path the table claims to cover.
*/
export const IMPLICIT_SESSION_PATHS = Object.freeze(['grep', 'glob', 'recall'])
/** The tool this plugin registers, and the action on it that widens the scope. */
export const FACET_TOOL = 'facet'
export const WIDENING_ACTION = 'add'
@@ -66,7 +88,10 @@ export const WIDENING_ACTION = 'add'
* Anything that is not a non-empty string is skipped.
*
* An empty result means "no opinion" — either the tool names no path (so it
* cannot leave the facet) or it names one in a shape we refuse to guess at.
* cannot leave the facet) or it names one in a shape we refuse to guess at. The
* first half of that is not true of every tool: the ones whose own default is a
* directory are listed in {@link IMPLICIT_SESSION_PATHS}, and the guard judges
* them against the directory they will actually search.
*
* @param {string} toolName - the tool being called.
* @param {unknown} args - the raw model arguments.
@@ -195,12 +220,12 @@ export function facetContextText(input) {
// the reason this guard writes never reaches it: `serviceAsk` replaces a
// rejected ask with `the user rejected tool "…"`. So the binding is the one
// channel that can say what is true, and it names the mechanism that still
// works — `extraRoots` is configuration applied by the profile, not a grant
// asked for per conversation.
// works: the facet's context panel, which the user owns and the agent has no
// write path to.
const reach = !canAsk
? 'refused — approval prompts are off in this session, so `facet action=add` cannot be approved either; only the user can widen this, by adding the path to `extraRoots`'
? '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 — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
? '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
@@ -236,6 +261,29 @@ export function outsideReason(input) {
return `${what} is ${verb} — this session's facet is ${root}. Ask the user to grant it (\`facet action=add\`), or work within the facet.`
}
/**
* Why a search that named no directory is being questioned.
*
* A separate sentence from {@link outsideReason} because the fault is different.
* There, the model named a path outside the facet and is told which one. Here it
* named nothing, so the path at issue is one it never wrote: the tool's own
* default. Saying so is the whole remedy — the fix is a second argument, not a
* grant — so the reason names the directory the search would have covered and
* the one it had to stay in.
*
* @param {object} input
* @param {string} input.cwd - the session working directory the tool defaults to.
* @param {string} input.root - the facet root.
* @returns {string} the reason.
*/
export function implicitSearchReason(input) {
const { cwd, root } = input
return (
`This search names no \`path\`, so it would cover this session's directory ${cwd} — and this session's ` +
`facet is ${root}. Name a folder inside the facet, or ask the user to grant the one you need.`
)
}
/**
* Why widening the scope is being put to the user.
*
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-facet",
"version": "0.1.4",
"version": "0.3.1",
"private": true,
"type": "module",
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
@@ -49,7 +49,9 @@
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager"
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-workspace"
]
}
},
+479 -17
View File
@@ -31,9 +31,13 @@ async function loadClient() {
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) {
@@ -60,7 +64,15 @@ async function loadClient() {
}
const require = (id) => {
if (id === 'react') return { createElement: (type, props, ...children) => ({ type, props, children }) }
if (id === 'react') {
return {
createElement: (type, props, ...children) => ({ type, props, children }),
// Single-pass stand-ins: the tests render a component once and read the
// tree, so a hook only has to answer, not to re-render.
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
}
}
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`client.js required an unexpected module: ${id}`)
}
@@ -75,17 +87,25 @@ async function loadClient() {
}
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {} } = {}) {
const seen = { formNs: undefined, slot: undefined, entry: undefined, card: undefined, watched: undefined }
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 }
return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true }
},
whileServed: (namespaces, register) => {
seen.watched = namespaces
@@ -97,12 +117,12 @@ function drive(mod, { served = true, value = {} } = {}) {
},
slots: {
inject: (name, register) => {
seen.slot = name
currentSlot = name
register()
currentSlot = undefined
},
register: (options, component) => {
seen.entry = options
seen.card = component
seen.registrations.push({ slot: currentSlot, options, component })
return () => {}
},
},
@@ -120,6 +140,13 @@ function drive(mod, { served = true, value = {} } = {}) {
return { seen, disposers }
}
/** The one registration a test cares about, by the slot it went into. */
function registrationFor(seen, slot) {
const found = seen.registrations.filter((entry) => entry.slot === slot)
assert.equal(found.length, 1, `expected exactly one registration into ${slot}`)
return found[0]
}
test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.name, 'dsh-facet')
@@ -130,44 +157,71 @@ test('the module is the one the Plugins page loads, and asks for the slots it re
test('the form binds to the profile entry id while the card registers under the package name', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const card = registrationFor(seen, 'plugins.bundle.config')
// These two are deliberately different strings. `facet-policy` is the `id` of
// this bundle's row in cordis.patch.yml — what `Config` is filed under. The
// card is dispatched by the BUNDLE's package name.
assert.equal(seen.formNs, 'facet-policy')
assert.equal(seen.entry.key, 'dsh-facet')
assert.notEqual(seen.formNs, seen.entry.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(card.options.key, 'dsh-facet')
assert.notEqual(seen.formNs, card.options.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(seen.slot, 'plugins.bundle.config')
assert.equal(card.slot, 'plugins.bundle.config')
assert.deepEqual(seen.watched, ['facet-policy'])
})
test('the context button rides the composer tool row, gated on the same namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const button = registrationFor(seen, 'conversation.input.left')
// An id, not a key: `conversation.input.left` is a list, and the row shows one
// entry per id.
assert.equal(button.options.id, 'facet-context')
assert.equal(typeof button.options.inject, 'function')
// It has to receive the settings form, because the panel writes through the
// very same document the plugin card does — 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, seen.entry.key)
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, card.options.key)
})
test('nothing is registered while the Host does not serve the namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod, { served: false })
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound')
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
// Neither the card nor the button: each would be a control that writes nowhere.
assert.deepEqual(seen.registrations, [])
})
test('the card stages exactly the fields the host marks volatile', async () => {
test('the card stages exactly the deployment fields the host marks volatile', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const declared = Object.entries(Config.dict)
const volatileFields = Object.entries(Config.dict)
.filter(([, schema]) => schema.meta.volatile === true)
.map(([key]) => key)
// `scopes` is volatile too, but it is not a deployment field and must not be a
// control on the card: it is per-session state, written by the context panel
// and keyed by session id. Staging it here would render a dictionary blob a
// user could not sensibly edit, and would put the panel's store on the one
// surface that edits the deployment.
assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it')
const declared = volatileFields.filter((key) => key !== 'scopes')
// A field served but never staged is invisible in the UI; a field staged but
// never served makes `whileServed` withhold the whole card.
assert.deepEqual(
@@ -204,11 +258,12 @@ test('the list field clears rather than storing an empty array that shadows the
test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: card } = registrationFor(seen, 'plugins.bundle.config')
assert.equal(typeof seen.card, 'function')
assert.equal(seen.card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
assert.equal(typeof card, 'function')
assert.equal(card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
const tree = seen.card({
const tree = card({
view: 'page',
useFacetCard: (select) =>
select({
@@ -236,3 +291,410 @@ test('the card renders both views without reaching for anything it was not given
assert.equal(controls[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared')
})
test('the client half loads the packages whose slots it registers into', async () => {
const pkg = (await import('../package.json', { with: { type: 'json' } })).default
const inject = pkg.dsh.client.inject
// Registering into a slot is a dependency on the package that declares it.
// `slots.inject` waits for a slot rather than creating one, so a half that
// names a slot belonging to a package the boot graph has not loaded yet
// registers nothing at all — and silently, because nothing errors.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
// 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 path or file\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 path or file\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))
// `footer` is a prop rather than a child, and it is rendered: a control there
// is as much part of the tree as one in the body.
walkTree(node.props?.children, visit)
walkTree(node.props?.footer, 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()
})
test('the extras picker names what it accepts and says what the list holds', 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 tree = mod.DirectoryPicker({
model,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: () => {},
})
// "folder or path" promised one thing and offered another: the rows are
// folders, and a file is added by naming it. The copy says both.
assert.equal(tree.props.title, 'Add a path or a file')
const texts = textsOf(tree)
assert.ok(texts.includes('Path or file'), 'the field is named for what it accepts')
assert.ok(
texts.some((text) => text.includes('The list holds folders')),
'the copy explains why no file rows are shown',
)
assert.ok(
buttonsOf(tree).some((button) => (button.children ?? [])[0] === 'Add'),
'the action is Add for an extra, not Set focus',
)
})
+186 -6
View File
@@ -107,6 +107,7 @@ before(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
facet = join(root, 'src', 'controllers', 'room')
await mkdir(facet, { recursive: true })
await mkdir(join(facet, 'inner'), { recursive: true })
await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true })
await mkdir(join(root, 'src', 'shared'), { recursive: true })
await writeFile(join(facet, 'index.ts'), 'export {}\n')
@@ -169,13 +170,59 @@ test('a prefix is not a parent', async () => {
assert.equal(decision.kind, 'ask')
})
test('an omitted path means the session directory, which is in scope', async () => {
test('an omitted path means the session directory, which here is the facet', async () => {
const { captured } = harness()
// Allowed because the default and the facet are the same folder — not because
// the call named nothing. The next test is the case where they differ.
assert.deepEqual(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() }), {
kind: 'allow',
})
})
test('a search that names no path is judged against the directory it would cover', async () => {
// The session directory is the facet's parent here: the user narrowed this
// conversation to `inner`, while the search tools still default to the whole
// session directory. An absent argument is not an absent path — it is the
// default one, and the default one leaves the facet.
const { captured } = harness({ config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' } } } })
const decision = await dispatch(captured, { name: 'grep', args: { pattern: 'motion_learning' }, agent: agent() })
assert.equal(decision.kind, 'ask')
assert.match(decision.reason, /names no `path`/)
assert.match(decision.reason, /inner/, 'the reason names the boundary the call crossed')
// Naming the folder is the whole remedy, and it costs no grant.
assert.deepEqual(
await dispatch(captured, { name: 'grep', args: { pattern: 'x', path: 'inner' }, agent: agent() }),
{ kind: 'allow' },
)
// The same rule reaches the other two tools whose default is a directory.
assert.equal(
(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() })).kind,
'ask',
)
assert.equal(
(await dispatch(captured, { name: 'recall', args: { query: 'motion learning' }, agent: agent() })).kind,
'ask',
)
// A tool with no path argument at all is still left alone: it has no default
// that could leave the facet.
assert.deepEqual(await dispatch(captured, { name: 'todo_write', args: { todos: [] }, agent: agent() }), {
kind: 'allow',
})
})
test('recall is judged on the scope it names, because that is what it will search', async () => {
const { captured } = harness({ config: { mode: 'deny', scopes: { 'session-1': { focus: 'inner' } } } })
assert.deepEqual(await dispatch(captured, { name: 'recall', args: { query: 'x', focus: 'inner' }, agent: agent() }), {
kind: 'allow',
})
assert.equal(
(await dispatch(captured, { name: 'recall', args: { query: 'x', path: '..' }, agent: agent() })).kind,
'deny',
)
})
test('tools the guard cannot judge are left alone, deliberately', async () => {
const { captured } = harness()
// A shell command cannot be read reliably from argv, so the facet is enforced
@@ -194,6 +241,84 @@ test('an agent with no working directory is not scoped', async () => {
assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: '/etc/hosts' } }), { kind: 'allow' })
})
// ── delegated sessions ───────────────────────────────────────────────────────
//
// A session spawned or forked from a scoped one has no declaration of its own,
// and the user's declaration is keyed by session id. Without inheritance its
// facet would be its own working directory — wider than the conversation it came
// from, which is the one direction this plugin promises cannot happen.
/** A session that was spawned from another, as the harness records lineage. */
const childOf = (parentId, id = 'child-1', cwd = facet) => ({
session: { id, header: { cwd, parentSession: parentId } },
})
test('a session spawned from a scoped one starts inside the same facet', async () => {
const { captured } = harness({ config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' } } } })
// The parent's root is on the record as soon as it resolves a scope, which
// happens for every model request and every tool call — so before it can
// spawn anything.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
{ kind: 'allow' },
)
const child = childOf('session-1')
// Without inheritance this would be ordinary work for the child: its root
// would be the session directory, one level up from its parent's facet.
const decision = await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: child })
assert.equal(decision.kind, 'ask')
assert.match(decision.reason, /inner/)
// Inside the inherited facet is inside the child's scope, as it should be.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: child }),
{ kind: 'allow' },
)
})
test('inheritance holds at depth, and is not a way around a self-narrowing', async () => {
// No declaration: the user left the session at its directory, and the only
// thing that will keep a child inside `inner` is the inherited root.
const { captured } = harness({ config: { mode: 'deny' } })
await captured.tool.execute({ action: 'focus', paths: ['inner'] }, { agent: agent() })
const child = childOf('session-1')
const grandchild = childOf('child-1', 'grand-1')
// The child's own scope is resolved by its first call, which is what puts its
// root on the record for the grandchild to inherit.
for (const descendant of [child, grandchild]) {
assert.equal(
(await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: descendant })).kind,
'allow',
`${descendant.session.id} is inside the inherited facet`,
)
assert.equal(
(await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: descendant })).kind,
'deny',
`${descendant.session.id} cannot delegate its way back out of the narrowing`,
)
}
})
test('a declaration for the child itself stands over what it would inherit', async () => {
const { captured } = harness({
config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' }, 'child-1': { focus: '.' } } },
})
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
{ kind: 'allow' },
)
// The user is the authority on this session too: a declaration made for the
// child is a statement about the child, and inheritance only answers the case
// where they made none.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: childOf('session-1') }),
{ kind: 'allow' },
)
})
test('a standing grant and a session grant both open the scope', async () => {
const standing = harness({ extraRoots: ['../../shared'] })
assert.deepEqual(
@@ -293,9 +418,10 @@ test('the binding refuses honestly when the session has no approval channel', ()
const { captured } = harness({ mode: 'ask', approval: 'never' })
const text = captured.context.text({ agent: agent() })
assert.match(text, /approval prompts are off in this session/)
assert.match(text, /cannot be approved either/)
assert.match(text, /`extraRoots`/)
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/)
@@ -308,14 +434,14 @@ test('an approval channel keeps the promise, and a missing service counts as non
// No approval service at all: the tool pipeline denies an ask it cannot route,
// so the binding must not offer one.
const without = harness({ mode: 'ask' })
assert.match(without.captured.context.text({ agent: agent() }), /approval prompts are off in this session/)
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, /`extraRoots`/)
assert.match(text, /context panel/)
})
test('the facet tool reports the scope', async () => {
@@ -347,6 +473,60 @@ test('the facet tool grants, narrows and clears', async () => {
assert.deepEqual(policy.grantsFor('session-1'), [])
})
test('the facet tool narrows inside the facet and refuses a folder outside it', async () => {
const { captured, policy } = harness()
const exec = { agent: agent() }
// Narrowing is the one scope change the agent may make unasked: it can only
// ever reduce what is visible, so there is nothing for a user to approve.
const narrowed = await captured.tool.execute({ action: 'focus', paths: ['inner'] }, exec)
assert.equal(narrowed.root, join(facet, 'inner'))
assert.equal(narrowed.facet, join(facet, 'inner'))
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
// A folder outside the scope already in force is a widening by another name,
// and widening is the user's decision, taken in their panel.
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['../../shared'] }, exec),
/outside the current facet/,
)
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['../other'] }, exec),
/outside the current facet/,
)
// A refused narrowing must not have moved anything.
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
})
test('the facet tool needs exactly one folder to focus on', async () => {
const { captured } = harness()
const exec = { agent: agent() }
await assert.rejects(() => captured.tool.execute({ action: 'focus' }, exec), /exactly one folder/)
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['inner', '../other'] }, exec),
/exactly one folder/,
)
})
test('reset lands on the scope the user declared, never back on the session directory', async () => {
const { captured, policy } = harness({
config: { mode: 'deny', scopes: { 'session-1': { focus: 'src/controllers/room' } } },
})
const exec = { agent: agent(root) }
// The user declared this conversation to be about the room controller, so the
// session is already narrower than the directory it was started in.
assert.equal(policy.scopeFor(agent(root)).root, facet)
await captured.tool.execute({ action: 'focus', paths: ['src/controllers/room/inner'] }, exec)
assert.equal(policy.scopeFor(agent(root)).root, join(facet, 'inner'))
// Clearing the agent's side has to land on the user's declaration. Landing on
// the session directory would make `reset` a widening.
await captured.tool.execute({ action: 'reset' }, exec)
assert.equal(policy.scopeFor(agent(root)).root, facet)
})
test('the facet tool refuses a call it cannot act on', async () => {
const { captured } = harness()
const exec = { agent: agent() }
+152
View File
@@ -93,3 +93,155 @@ test('prompt placements are carried, so the preset row needs no config', () => {
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7)
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8)
})
// ── per-session scope ────────────────────────────────────────────────────────
//
// The declaration is the user's, written from the browser panel; the narrowing
// is the agent's, held in memory. Everything below is about which of the two
// wins, and the answer is always "the narrower one, and never a stale agent
// focus that the user has since narrowed past".
test('a declared focus narrows the root, and the session directory stays the base', () => {
const policy = createFacetPolicy({
mode: 'deny',
scopes: { s1: { focus: 'src/controllers/room' } },
})
const scope = policy.scopeFor(agentAt('/w', 's1'))
assert.equal(scope.root, '/w/src/controllers/room')
// `cwd` is still where relative paths resolve from — a declaration written
// against the session keeps its meaning whatever the session focuses on.
assert.equal(scope.cwd, '/w')
})
test('a declaration is read live, so the panel needs no remount', () => {
const scopes = {}
const policy = createFacetPolicy({ mode: 'deny', scopes })
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
// What a panel write does to the running reference.
scopes.s1 = { focus: 'src/module' }
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/module')
})
test('declared extras join the deployment seed and the session grants', () => {
const policy = createFacetPolicy({
mode: 'deny',
extraRoots: ['seed'],
scopes: { s1: { focus: 'src/room', extras: ['shared', '/abs/common'] } },
})
policy.grant('s1', ['granted'], '/w')
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, [
'/w/seed',
'/w/shared',
'/abs/common',
'/w/granted',
])
})
test('an agent narrowing is honoured while it stays inside the declared root', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src' } } })
policy.narrowTo('s1', '/w/src/room')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
assert.equal(policy.narrowFor('s1'), '/w/src/room')
})
test('a narrowing the user has since narrowed past is dropped, not honoured', () => {
const scopes = { s1: { focus: 'src' } }
const policy = createFacetPolicy({ mode: 'deny', scopes })
policy.narrowTo('s1', '/w/src/room')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
// The user narrows to a sibling: the agent's focus is now outside the scope
// they declared. Honouring it would put the session back where the user had
// just decided it should not be.
scopes.s1 = { focus: 'src/other' }
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/other')
})
test('reset and forget drop the agent side and leave the declaration alone', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src', extras: ['shared'] } } })
policy.narrowTo('s1', '/w/src/room')
policy.grant('s1', ['granted'], '/w')
policy.reset('s1')
// Back to what the user declared — narrower than the session, never wider.
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
assert.deepEqual(policy.grantsFor('s1'), [])
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/shared'])
policy.narrowTo('s1', '/w/src/room')
policy.forget('s1')
assert.equal(policy.narrowFor('s1'), undefined)
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
})
test('junk in a declaration is ignored rather than thrown on', () => {
const policy = createFacetPolicy({
mode: 'deny',
scopes: { s1: { focus: ' ', extras: ['ok', '', 42, null] }, s2: 'not an object' },
})
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok'])
assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w')
})
// ── delegated sessions ───────────────────────────────────────────────────────
//
// A session spawned or forked from this one has no declaration of its own, and
// the user's declaration is keyed by session id. What stops it starting wider
// than its parent is the parent's own resolved root, recorded as the parent
// resolves a scope — which it does for every model request and every tool call,
// so the record is always there before it can spawn anything.
/** An agent whose header carries the lineage the harness writes for a child. */
const spawned = (id, parentSession, cwd = '/w') => ({
session: { id, header: { cwd, parentSession } },
})
test('a session spawned from a scoped one inherits its root, at any depth', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' } } })
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
const child = spawned('c1', 's1')
assert.equal(policy.scopeFor(child).root, '/w/src/room')
// Relative paths still resolve from the child's own directory, as for anyone.
assert.equal(policy.scopeFor(child).cwd, '/w')
assert.equal(policy.scopeFor(spawned('g1', 'c1')).root, '/w/src/room')
})
test('a child is never wider than its parent is now, narrowing included', () => {
const policy = createFacetPolicy({ mode: 'deny' })
policy.narrowTo('s1', '/w/src/room')
// The narrowing is the agent's own decision; spawning a child is not a way to
// undo it, because the root the child inherits already includes it.
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
assert.equal(policy.scopeFor(spawned('c1', 's1')).root, '/w/src/room')
})
test('a declaration for the child itself stands over what it would inherit', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' }, c1: { focus: '.' } } })
policy.scopeFor(agentAt('/w', 's1'))
// Inheritance answers the case where the user made no statement about this
// session; a statement they did make is the more specific one and stands.
assert.equal(policy.scopeFor(spawned('c1', 's1')).root, '/w')
})
test('a root is kept while a session that inherited it is still alive', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' } } })
const child = spawned('c1', 's1')
policy.scopeFor(agentAt('/w', 's1'))
policy.scopeFor(child)
// Disposing the parent must not quietly widen the child that is still running.
policy.forget('s1')
assert.equal(policy.scopeFor(child).root, '/w/src/room')
// With nothing left to inherit it, the record goes too.
policy.forget('c1')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
})
+6 -6
View File
@@ -82,7 +82,7 @@ test('facetContextText carries the binding that does change', () => {
deny,
'facet: /w/src/room\n' +
'granted outside it: src/shared\n' +
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
)
// The binding is the only half that can follow a live edit, so the two modes
@@ -97,14 +97,14 @@ test('facetContextText carries the binding that does change', () => {
test('a session with no approval channel says so instead of promising a question', () => {
// `approval: never` refuses every ask before an answerer is consulted, so the
// mode stops mattering: `ask` and `deny` both end in a refusal, and
// `facet action=add` cannot be approved either. `canAsk: false` is the one
// `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 — `extraRoots` is configuration, not a per-conversation grant.
// 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 — approval prompts are off in this session/)
assert.match(text, /`facet action=add` cannot be approved either/)
assert.match(text, /`extraRoots`/)
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/)
}