6 Commits
Author SHA1 Message Date
gitea-actions 608b59639c Release v0.4.0 [skip release] [skip actions] 2026-09-28 06:51:00 +00:00
NotExpectedYet c49acfdb2c feat: list files in the extras picker, not just folders
Release / release (push) Successful in 6s
The extras control accepts a file — the field and its label said so — and the
listing under it could not show one, because the Host primitive behind the picker
browses folders and nothing else. So a grant for a single file meant typing its
name exactly, which is the one thing a listing exists to avoid.

The Host has a second lister and it does return files: `remote.workspaceFiles.list`,
the same listing behind the sidebar's own file tree, confined to the session's
workspace. The extras picker now takes that one while the level is inside the
session folder — which is where an extra usually is — and falls back to the folder
lister above it, saying so in the dialog rather than letting file rows vanish. The
focus picker never offers files: a focus is a folder, so a file row there would
offer something that cannot be chosen.

The two listings normalise into one level shape, so the picker does not care which
answered, and a row now carries its type: a folder is a way down, a file is a
thing to take. Clicking a file fills the path field and marks the row, and the
footer adds it in declared form. Pressing Enter on a name the level already lists
is no longer reported as a failure — the path is exactly what the dialog is for.
Everything stays additive: with no workspace file service, or above the session
folder, the picker lists folders exactly as before.
2026-09-28 07:47:24 +01:00
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
10 changed files with 1013 additions and 84 deletions

No files matched your search

+16
View File
@@ -2,6 +2,22 @@
Every release, newest first, generated from the commits in each release.
## v0.4.0 — 2026-09-28
### Added
- list files in the extras picker, not just folders
## 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
+73 -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 |
@@ -145,6 +154,15 @@ 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
@@ -155,13 +173,32 @@ 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.
panel, shaped like the host's own workspace browser — one level of children, a
breadcrumb trail, a hidden-entries toggle, and a file-type glyph per row. 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 listing is driven by **two Host listers**, because they answer different
questions and the extras need both:
| lister | returns | reach |
|---|---|---|
| `uiWorkspace.listDirectory` | folders | anywhere on the machine |
| `remote.workspaceFiles.list` | files and folders | the session's workspace only |
The extras picker takes the file listing while the level is inside the session
folder, which is how a file is chosen by clicking its name instead of typing it —
the same listing behind the sidebar's own file tree, applied one level at a time.
Above the session folder the Host confines it, and the level falls back to folders
and says so in the dialog: a file outside the workspace is still added by typing
its path, which is why the action reads **Add path or file** and its field **Path
or file**. The focus picker never offers files at all — a focus is a folder, so a
file row there would offer something that cannot be chosen.
A folder row is a way down; a file row is a thing to take. Clicking a file fills
the path field and marks the row, and the footer's action then adds it in declared
form; pressing Enter on a name the level already lists is not an error, because
the path is exactly what this dialog is for.
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
@@ -207,7 +244,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.
@@ -224,26 +264,32 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified:
- 73 tests (`npm test`) over the scope algebra, the policy state, the guard, and
- 93 tests (`npm test`) over the scope algebra, the policy state, the guard, and
the browser half — including that containment is decided on canonical targets
(a symlink inside the facet pointing outside it is refused), that a prefix is
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
call never makes the guard throw, that a live `mode` edit decides the next call
without the guard being remounted, that an agent narrowing the user has since
narrowed past is dropped rather than honoured, that `reset` lands on the
declared scope rather than the session directory, and that every `facet` result
satisfies the tool's own declared output schema.
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, that both listings normalise into one picker level and
that a file listing the Host confines still leaves a browsable one, 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
@@ -308,7 +354,7 @@ lib/scope.js the pure half — argument extraction, text, reason
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/ 78 tests
test/ 93 tests
```
`client/client.js` is a browser half rather than a module the host imports, and
@@ -333,14 +379,16 @@ 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 listings reach the panel through `ctx.get` rather than through an injection —
`uiWorkspace` for folders and `remote.workspaceFiles` for files — and each
`available()` is asked on every render. Both are deliberate: `ctx.inject` would
make the whole context button conditional on services 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 services are 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. Both packages are named in this bundle's `dsh.client.inject`, so
a profile that installs this one gets the services its dialog asks for.
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
+282 -33
View File
@@ -46,6 +46,7 @@ window.__ModuleLoader__.load({
SettingsFormModel,
SettingsValueField,
IconFolderCloseRegular,
FileTypeIcon,
} = primitives
const h = react.createElement
@@ -448,7 +449,10 @@ window.__ModuleLoader__.load({
*
* The Host's own business message is preferred to the transport error,
* because a browse refusal is an answer — the folder is unreadable, the path
* is not absolute — and that sentence is the one worth reading.
* is not absolute — and that sentence is the one worth reading. Both listers
* reach here: the folder primitive rejects with its own error and the remote
* one folds its failure into a `RemoteError`, which is an `Error` carrying
* the same message field.
*/
function browseFailure(error) {
const rpc = error !== null && typeof error === 'object' ? error.rpcError : undefined
@@ -456,6 +460,121 @@ window.__ModuleLoader__.load({
return error instanceof Error ? error.message : String(error)
}
// ── one level, in the picker's own vocabulary ────────────────────────────
//
// The Host has two listers and they answer different questions, so the
// picker reads them through one shape and does not care which answered:
//
// * `uiWorkspace.listDirectory` browses anywhere and returns folders only;
// * `remote.workspaceFiles.list` returns files as well, and confines itself
// to the session's workspace — the same listing the sidebar's file tree
// drives, and the only way this deployment can show a file at all.
//
// `{base, crumbs, entries, truncated, files}` is that shape. `files` says
// whether this level could carry file rows, which is what the copy under the
// path field tells the user.
/** Join one name onto a directory, with the separator the Host resolves. */
function joinUnder(base, name) {
return `${String(base).replace(/[\\/]+$/, '')}/${name}`
}
/** Whether a path is a directory or sits inside one, either separator. */
function withinPath(inner, outer) {
const path = String(inner).replace(/\\/g, '/').replace(/\/+$/, '')
const base = String(outer).replace(/\\/g, '/').replace(/\/+$/, '')
return base !== '' && (path === base || path.startsWith(`${base}/`))
}
/** The last segment of a path, for a trail's first crumb. */
function basenameOf(path) {
const segments = pathSegments(path)
return segments.length === 0 ? String(path) : segments[segments.length - 1]
}
/**
* The ancestry of one path inside the workspace, workspace root first.
*
* The folder primitive reports its own trail — from the filesystem root, so
* the user can go above the session — and the file listing reports none,
* because the workspace root is as far up as it goes. This builds the one it
* does not send.
*/
function trailTo(workspace, path) {
const trail = [{ name: basenameOf(workspace), path: workspace }]
const rest = relativeTo(workspace, path)
// A path outside the workspace cannot be walked to from it, and `relativeTo`
// says so by answering in `..` segments or absolutely.
if (rest === '' || rest.startsWith('..') || isAbsolutePath(rest)) return trail
let walked = workspace
for (const segment of rest.split('/')) {
if (segment === '') continue
walked = joinUnder(walked, segment)
trail.push({ name: segment, path: walked })
}
return trail
}
/**
* One folder listing, as the picker reads it.
*
* @param {object} listing - a `DirectoryListing` from the browse primitive.
* @param {string} [fallback] - the path asked for, when the answer carries none.
* @returns {object} the picker's level.
*/
function folderListing(listing, fallback) {
const base = typeof listing?.path === 'string' && listing.path !== '' ? listing.path : String(fallback ?? '')
const home = typeof listing?.home === 'string' ? listing.home : ''
return {
base,
crumbs: (Array.isArray(listing?.crumbs) ? listing.crumbs : []).map((crumb) => ({
// A root crumb carries its whole path as its name; "Home" is the label
// the host's own browser gives it.
name: crumb.path === home ? 'Home' : crumb.name,
path: crumb.path,
})),
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
name: entry.name,
path: entry.path,
directory: true,
hidden: entry.hidden === true,
})),
truncated: listing?.truncated === true,
files: false,
}
}
/**
* One workspace listing, as the picker reads it.
*
* Paths arrive relative to the workspace root and the picker works in
* absolute paths, so they are joined here — with `/`, whatever the level's
* own separators, because the Host resolves mixed separators and this is only
* a key and something to display.
*
* @param {object} listing - a `WorkspaceDirectoryListing`.
* @param {string} workspace - the session folder, absolute.
* @returns {object} the picker's level.
*/
function workspaceListing(listing, workspace) {
const relative = typeof listing?.path === 'string' ? listing.path : ''
const base = relative === '' ? workspace : joinUnder(workspace, relative)
return {
base,
crumbs: trailTo(workspace, base),
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
name: entry.name,
path: joinUnder(base, entry.name),
directory: entry.type === 'directory',
// The workspace listing has no hidden flag; the platform convention is
// the dot, which is what the folder primitive reports for the same name.
hidden: String(entry.name).startsWith('.'),
})),
truncated: listing?.truncated === true,
files: true,
}
}
/**
* The state behind the picker: the level on screen, the path being edited,
* and the last failure.
@@ -466,17 +585,25 @@ window.__ModuleLoader__.load({
* a sequence number and only the newest may commit. It also keeps the whole
* interaction testable without a DOM, which is the same reason the settings
* card has a controller.
*
* Two listers in, one level out: see the note above {@link folderListing}
* for why there are two and what the picker does with either.
*/
var DirectoryPickerModel = class {
/**
* @param {object} options
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.list - the host listing call.
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.folders - the folder listing; browses anywhere.
* @param {(sessionId: string, path: string, signal?: AbortSignal) => Promise<object>} [options.files] - the file listing; files and folders, confined to the workspace.
* @param {string} [options.sessionId] - the session whose workspace the file listing is confined to.
* @param {string} options.root - the session folder, absolute, or ''.
* @param {string} [options.start] - the directory to open first; the root by default.
*/
constructor(options) {
const root = typeof options.root === 'string' ? options.root : ''
this.list = options.list
this.folders = options.folders
this.files = options.files
this.sessionId = options.sessionId
this.root = root
this.listeners = new Set()
this.request = 0
this.state = {
@@ -524,15 +651,53 @@ window.__ModuleLoader__.load({
const target = String(path ?? '').trim()
const request = (this.request += 1)
this.update({ loading: true, error: '', draft: target })
Promise.resolve()
.then(() => this.list(target === '' ? undefined : target))
.then(
(listing) => {
this.read(target, request)
}
/**
* One level, from whichever lister can answer for it.
*
* The file listing is tried first while the level is inside the session's
* workspace, because that is the listing the extras need: it carries files
* as well as folders. The Host confines it to the workspace, so a level
* above the workspace root — or a deployment with no file service — falls
* back to the folder listing, which browses anywhere and carries folders
* only. The fallback is also the answer to a file listing that failed for
* any other reason, so a level the file service cannot read is still
* browsable as folders rather than reported as unreadable.
*
* @param {string} target - the absolute directory, or '' for the Host's home.
* @param {number} request - the sequence number this read belongs to.
*/
read(target, request) {
const files = this.files
const inside = target === '' ? this.root !== '' : withinPath(target, this.root)
const asFolders = () => this.folders(target === '' ? undefined : target).then((listing) => folderListing(listing, target))
const listing =
files === undefined || !inside
? asFolders()
: files(this.sessionId, target === '' ? this.root : target)
.then((answer) => workspaceListing(answer, this.root))
.catch(asFolders)
Promise.resolve(listing).then(
(level) => {
if (request !== this.request) return
this.update({ listing, loading: false, error: '', draft: String(listing?.path ?? target) })
this.update({ listing: level, loading: false, error: '', draft: level.base })
},
(reason) => {
if (request !== this.request) return
// A path that names a file in the level already on screen is not a
// failure, it is the thing this dialog is for: the list asked for is
// not a list, and the footer can still add what it names. That
// matters now that a file row is one keystroke away from the field.
const wanted = absoluteFrom(this.root, target)
const named = (this.state.listing?.entries ?? []).some(
(entry) => entry.directory !== true && entry.path === wanted,
)
if (named) {
this.update({ loading: false, error: '' })
return
}
this.update({ loading: false, error: browseFailure(reason) })
},
)
@@ -571,6 +736,13 @@ window.__ModuleLoader__.load({
}
const pickerNameStyle = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
const pickerGlyphStyle = { display: 'inline-flex', flex: 'none', color: 'var(--dsw-alias-label-secondary)' }
/** A file row the path field already names, so a click is visibly a choice. */
const pickerChosenRowStyle = {
...pickerRowStyle,
// The token the Host's own directory browser uses for a chosen row, fallback
// and all, so a pick looks the same here as it does one panel over.
background: 'var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))',
}
const crumbStyle = {
flex: 'none',
border: 0,
@@ -599,7 +771,13 @@ window.__ModuleLoader__.load({
/**
* The directory picker: one editable path, the ancestry above it, and one
* level of child folders.
* level of children — folders always, and files wherever the Host will list
* them.
*
* A folder row is a way down; a file row is a thing to take, so it fills the
* path field rather than navigating, and the primary action adds what the
* field holds. That is the one interaction the two row kinds do not share,
* and it is why a file row is marked selected once the field names it.
*
* It drives no host call of its own — {@link DirectoryPickerModel} holds the
* listing and the path, and the panel builds one per opening so each visit
@@ -622,6 +800,9 @@ window.__ModuleLoader__.load({
// folder: the focus's own default, and for an extra — already the root —
// nothing worth adding.
const declared = relativeTo(root, typed)
// The field's own value as an absolute path, so a file row can tell whether
// it is the one the field names.
const chosen = absoluteFrom(root, typed)
const rows = entries.map((entry) =>
h(
@@ -630,14 +811,22 @@ window.__ModuleLoader__.load({
key: entry.path,
type: 'button',
className: ROW_CLASS,
style: pickerRowStyle,
style: entry.directory || entry.path !== chosen ? pickerRowStyle : pickerChosenRowStyle,
title: entry.path,
disabled: blocked || state.loading,
// A click walks down rather than picking: the primary action is the
// path field's own value, so a folder is chosen by standing in it.
onClick: () => model.open(entry.path),
// A folder is chosen by standing in it; a file is chosen by naming
// it, so the click puts it in the field for the footer to add. The
// field holds an absolute path everywhere else in this dialog, and
// the declared form is what the panel's write turns it into.
onClick: () => (entry.directory || target === 'focus' ? model.open(entry.path) : model.setDraft(entry.path)),
},
h('span', { style: pickerGlyphStyle }, h(IconFolderCloseRegular, { size: 14 })),
h(
'span',
{ style: pickerGlyphStyle },
entry.directory
? h(IconFolderCloseRegular, { size: 14 })
: h(FileTypeIcon, { path: entry.name, size: 16 }),
),
h('span', { style: pickerNameStyle }, entry.name),
),
)
@@ -656,10 +845,7 @@ window.__ModuleLoader__.load({
disabled: blocked || state.loading,
onClick: () => model.open(crumb.path),
},
// A root crumb carries its whole path as its name; "Home" is the
// shorter label the host's own browser gives it, and the title
// attribute still names where it is.
crumb.path === listing.home ? 'Home' : crumb.name,
crumb.name,
),
)
}
@@ -672,15 +858,24 @@ window.__ModuleLoader__.load({
: listing === null
? h('p', { key: 'status', style: pickerStatusStyle }, 'Type a folder to start from.')
: entries.length === 0
? h('p', { key: 'status', style: pickerStatusStyle }, 'No folders in here.')
? h('p', { key: 'status', style: pickerStatusStyle }, 'Nothing in here.')
: listing.truncated === true
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many folders to list; only the beginning is shown.')
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many entries to list; only the beginning is shown.')
: // Above the session folder the Host's file listing stops — it is
// confined to the workspace — so the rows become folders only.
// Saying so is better than letting file rows silently vanish.
target === 'extra' && listing.files !== true
? h(
'p',
{ key: 'status', style: pickerStatusStyle },
'Files are listed inside the session folder only; above it the list is folders.',
)
: null
return h(Modal, {
open: true,
onClose,
title: target === 'focus' ? 'Choose the focus folder' : 'Add a folder or a path',
title: target === 'focus' ? 'Choose the focus folder' : 'Add a path or a file',
closeLabel: 'Close',
className: PICKER_CLASS,
// One scroll region around everything but the footer, so the card is
@@ -694,7 +889,7 @@ window.__ModuleLoader__.load({
h(
'div',
{ key: 'path' },
h('label', { style: labelStyle, htmlFor: 'facet-picker-path' }, target === 'focus' ? 'Folder' : 'Path'),
h('label', { style: labelStyle, htmlFor: 'facet-picker-path' }, target === 'focus' ? 'Folder' : 'Path or file'),
h('input', {
id: 'facet-picker-path',
type: 'text',
@@ -702,8 +897,9 @@ window.__ModuleLoader__.load({
value: typed,
disabled: blocked,
spellCheck: false,
// The Host lists folders, so this box is also how a file — or a
// folder the session will create — is named at all.
// The listing carries the files it can reach; the box is still how a
// path outside the session's workspace — or a folder the session
// will create — is named at all.
'data-modal-autofocus': true,
placeholder: root === '' ? absoluteFrom(root, 'src/shared') : 'relative/to/the/session/folder',
onChange: (event) => model.setDraft(event.target.value),
@@ -713,7 +909,16 @@ window.__ModuleLoader__.load({
model.open(typed)
},
}),
h('p', { style: hintStyle }, 'Relative to the session folder, or absolute. Enter lists whatever is there.'),
h(
'p',
{ style: hintStyle },
target === 'focus'
? 'Relative to the session folder, or absolute. Enter lists whatever is there.'
: // Files where the Host lists them, folders everywhere. The box is
// still where a path the listing cannot reach gets named, so both
// halves of that are said rather than left to be discovered.
'Relative to the session folder, or absolute. Folders open; a file is taken as the path to add.',
),
),
h(
'div',
@@ -834,6 +1039,24 @@ window.__ModuleLoader__.load({
)
}
/**
* The file lister an opening should offer, if any.
*
* Files are what an extra may name, so that is the only control which offers
* them: a focus is a folder, and a listing that could hand the picker a file
* would only offer something that cannot be chosen. A deployment without the
* file service gets the folder listing as before, which is why this asks
* rather than assumes.
*
* @param {object} browse - the browsing face from {@link directoryBrowse}.
* @param {string} target - 'focus' or 'extra'.
* @returns {Function|undefined} the lister, or nothing to offer.
*/
function fileLister(browse, target) {
if (target !== 'extra') return undefined
return browse?.files?.available?.() === true ? browse.files.list : undefined
}
/**
* The facet context panel: what this conversation may see, and who decides.
*
@@ -847,8 +1070,8 @@ window.__ModuleLoader__.load({
* "which folder is `../shared`" is exactly the question a free-text box
* cannot answer; the listing answers it from the Host's own directory rather
* than from a guess about what the path was meant to name. The path field is
* still editable inside the picker, because the Host lists folders and the
* extras may name a file.
* still editable inside the picker, for a path no listing reaches: a folder
* the session will create, or a file above the session's workspace.
*
* @param {object} props - the bound form, the session, its folder, the browse face, and the close command.
*/
@@ -902,7 +1125,9 @@ window.__ModuleLoader__.load({
setPicker({
target,
model: new DirectoryPickerModel({
list: browse.list,
folders: browse.list,
files: fileLister(browse, target),
sessionId,
root,
start: isAbsolutePath(stored) ? stored : '',
}),
@@ -1007,7 +1232,7 @@ window.__ModuleLoader__.load({
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
declared.extras.length === 0
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.')
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a single file go here.')
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
h(
'div',
@@ -1015,7 +1240,7 @@ window.__ModuleLoader__.load({
h(
'button',
{ type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('extra') },
'+ Add folder or path\u2026',
'+ Add path or file\u2026',
),
),
h(
@@ -1146,7 +1371,7 @@ window.__ModuleLoader__.load({
}
/**
* The directory-browsing face the picker drives.
* The browsing face the picker drives: two listers, because the Host has two.
*
* `uiWorkspace` is read lazily rather than injected. Injecting it would make
* the whole context button conditional on it, and the panel is still the
@@ -1159,8 +1384,8 @@ window.__ModuleLoader__.load({
* would freeze at whatever the first render saw, and the service may well
* activate after this bundle does.
*
* @param {object} ctx - the client context to resolve the service from.
* @returns {object} `available()` and `list()`, the two calls the panel makes.
* @param {object} ctx - the client context to resolve the services from.
* @returns {object} `available()`/`list()` for folders, and the same pair again under `files`.
*/
function directoryBrowse(ctx) {
const workspace = () => ctx.get('uiWorkspace')
@@ -1174,6 +1399,24 @@ window.__ModuleLoader__.load({
}
return service.listDirectory(path, signal)
},
// Folders only, anywhere on the machine. The file lister below is what
// makes an extra selectable by name; this one is what makes it
// reachable at all, and the two are needed together.
files: {
available: () => typeof ctx.get('remote.workspaceFiles')?.list === 'function',
list: (sessionId, path, signal) => {
const server = ctx.get('remote.workspaceFiles')
if (server === undefined || typeof server.list !== 'function') {
return Promise.reject(new Error('This deployment cannot list files.'))
}
// The Host confines this to the Session's workspace, which is why the
// model falls back to the folder listing above the session folder.
return server.list(sessionId, path, signal).then((result) => {
if (result?.ok !== true) throw result?.error ?? new Error('The Host refused that listing.')
return result.value
})
},
},
}
}
@@ -1257,7 +1500,13 @@ window.__ModuleLoader__.load({
absoluteFrom,
relativeTo,
isAbsolutePath,
withinPath,
joinUnder,
trailTo,
folderListing,
workspaceListing,
directoryBrowse,
fileLister,
}
return module.exports
},
+16 -1
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)
+94 -7
View File
@@ -97,6 +97,29 @@ export function createFacetPolicy(config) {
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.
@@ -112,6 +135,23 @@ export function createFacetPolicy(config) {
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.
*
@@ -123,16 +163,22 @@ export function createFacetPolicy(config) {
* 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) {
function rootFor(sessionId, sessionCwd, inherited) {
const declared = declaredScope(sessionId)?.focus
const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd
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) return base
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
if (agentFocus !== undefined && within(agentFocus, base)) return agentFocus
return base
}
@@ -162,7 +208,13 @@ export function createFacetPolicy(config) {
* resolve against. A declaration written relative to the session keeps the
* same meaning whatever the session is focused on.
*
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
* 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) {
@@ -170,9 +222,13 @@ export function createFacetPolicy(config) {
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
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: rootFor(sessionId, sessionCwd),
root,
extras: extrasFor(agent, sessionCwd),
}
}
@@ -284,10 +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 {
@@ -325,6 +393,25 @@ 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.
*
+49 -1
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.
@@ -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.
*
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-facet",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
@@ -51,7 +51,8 @@
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-workspace"
"@deepseek-ai/dsh-client-ui-workspace",
"@deepseek-ai/dsh-api-workspace-files"
]
}
},
+297 -14
View File
@@ -35,9 +35,10 @@ async function loadClient() {
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.
// The listing's row glyphs; a client half may only use what the primitives
// actually export, so the stand-ins are named the same way.
IconFolderCloseRegular: 'IconFolderCloseRegular',
FileTypeIcon: 'FileTypeIcon',
/** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class {
constructor(scope, specs) {
@@ -423,7 +424,7 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a
// already did.
assert.ok(buttons.some((button) => labelOf(button) === 'Browse\u2026'))
assert.ok(buttons.some((button) => labelOf(button) === 'Clear'))
assert.ok(buttons.some((button) => labelOf(button) === '+ Add folder or path\u2026'))
assert.ok(buttons.some((button) => labelOf(button) === '+ 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')
})
@@ -456,7 +457,7 @@ test('a deployment with no directory browser keeps the panel and says so', async
)
assert.deepEqual(
buttonsOf(tree)
.filter((button) => label(button) === '+ Add folder or path\u2026')
.filter((button) => label(button) === '+ Add path or file\u2026')
.map((button) => button.props.disabled),
[true],
)
@@ -520,7 +521,10 @@ function walkTree(node, visit) {
}
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 () => {
@@ -555,19 +559,46 @@ test('the browse face is read from uiWorkspace at the call, not at registration'
const missing = mod.directoryBrowse({ get: () => undefined })
assert.equal(missing.available(), false)
await assert.rejects(missing.list('/tmp'), /no directory browser/)
// The file lister is a second service and probes separately: a deployment can
// have the folder browser without the workspace file service.
assert.equal(missing.files.available(), false)
await assert.rejects(missing.files.list('session-1', '/tmp'), /cannot list files/)
const calls = []
const present = mod.directoryBrowse({
get: () => ({
get: (name) =>
({
uiWorkspace: {
listDirectory: (path, signal) => {
calls.push([path, signal])
calls.push(['folders', path, signal])
return Promise.resolve('listing')
},
}),
},
'remote.workspaceFiles': {
list: (sessionId, path, signal) => {
calls.push(['files', sessionId, path, signal])
return Promise.resolve({ ok: true, value: 'workspace listing' })
},
},
})[name],
})
assert.equal(present.available(), true)
assert.equal(await present.list('/tmp', 'signal'), 'listing')
assert.deepEqual(calls, [['/tmp', 'signal']])
assert.deepEqual(calls, [['folders', '/tmp', 'signal']])
// The remote answers with a result envelope rather than rejecting, so its
// failure branch is turned into one here — the model's catch expects a throw.
assert.equal(present.files.available(), true)
assert.equal(await present.files.list('session-1', '/work/root', 'signal'), 'workspace listing')
assert.deepEqual(calls[1], ['files', 'session-1', '/work/root', 'signal'])
const refusing = mod.directoryBrowse({
get: (name) =>
name === 'remote.workspaceFiles'
? { list: () => Promise.resolve({ ok: false, error: new Error('workspace files are unavailable') }) }
: undefined,
})
await assert.rejects(refusing.files.list('session-1', '/work/root'), /workspace files are unavailable/)
})
test('the picker model lists a level, supersedes a stale one, and keeps a typed path on failure', async () => {
@@ -575,7 +606,7 @@ test('the picker model lists a level, supersedes a stale one, and keeps a typed
const asked = []
const model = new mod.DirectoryPickerModel({
list: (path) => {
folders: (path) => {
asked.push(path)
return Promise.resolve(listingFixture())
},
@@ -585,7 +616,7 @@ test('the picker model lists a level, supersedes a stale one, and keeps a typed
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().listing.base, '/work/root/src')
assert.equal(model.getSnapshot().draft, '/work/root/src', 'the field follows a successful listing')
assert.equal(model.getSnapshot().loading, false)
@@ -594,10 +625,11 @@ test('the picker model lists a level, supersedes a stale one, and keeps a typed
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.
// A failure keeps the level on screen and the path as typed: a listing can
// fail on a path this panel may still legitimately declare.
const failing = new mod.DirectoryPickerModel({
list: () => Promise.reject(Object.assign(new Error('transport'), { rpcError: { message: 'directory-unreadable' } })),
folders: () =>
Promise.reject(Object.assign(new Error('transport'), { rpcError: { message: 'directory-unreadable' } })),
root: '/work/root',
start: 'notes/CONTROLLER.md',
})
@@ -610,7 +642,7 @@ test('the picker lists folders, hides hidden ones until asked, and answers in de
const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
list: () => Promise.resolve(listingFixture()),
folders: () => Promise.resolve(listingFixture()),
root: '/work/root',
start: '/work/root/src',
})
@@ -661,3 +693,254 @@ test('the picker lists folders, hides hidden ones until asked, and answers in de
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({
folders: () => 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 now carry
// files, and the copy says what a row of each kind does.
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('Folders open; a file is taken')),
'the copy explains what a file row does',
)
assert.ok(
buttonsOf(tree).some((button) => (button.children ?? [])[0] === 'Add'),
'the action is Add for an extra, not Set focus',
)
})
// ── files in the extras picker ───────────────────────────────────────────────
//
// Two Host listers, one of which carries files. The folder primitive browses
// anywhere and returns folders; the workspace file listing returns files and
// folders but confines itself to the session's workspace. The extras picker
// takes the file listing wherever the Host allows it, and the folder listing
// everywhere else, so a path above the session folder is still reachable.
/** A level shaped the way `remote.workspaceFiles.list` answers. */
function workspaceFixture() {
return {
path: 'src',
entries: [
{ name: 'shared', type: 'directory' },
{ name: '.git', type: 'directory' },
{ name: 'index.ts', type: 'file', size: 120 },
{ name: 'CONTROLLER.md', type: 'file', size: 900 },
{ name: 'socket', type: 'other' },
],
truncated: false,
}
}
test('the model takes the file listing inside the workspace and the folder listing above it', async () => {
const { module: mod } = await loadClient()
const asked = []
const model = new mod.DirectoryPickerModel({
folders: (path) => {
asked.push(['folders', path])
return Promise.resolve(listingFixture())
},
files: (sessionId, path) => {
asked.push(['files', sessionId, path])
return Promise.resolve(workspaceFixture())
},
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
// Inside the workspace: the file listing, normalised into the picker's own
// shape, with the workspace-relative paths joined onto the session folder.
assert.deepEqual(asked, [['files', 'session-1', '/work/root/src']])
const level = model.getSnapshot().listing
assert.equal(level.base, '/work/root/src')
assert.equal(level.files, true, 'this level could carry file rows')
assert.deepEqual(
level.entries.map((entry) => [entry.name, entry.path, entry.directory, entry.hidden]),
[
['shared', '/work/root/src/shared', true, false],
['.git', '/work/root/src/.git', true, true],
['index.ts', '/work/root/src/index.ts', false, false],
['CONTROLLER.md', '/work/root/src/CONTROLLER.md', false, false],
['socket', '/work/root/src/socket', false, false],
],
)
// The trail is the workspace's own, root first: the file listing stops at the
// session folder, so there is nothing above it to offer.
assert.deepEqual(
level.crumbs.map((crumb) => crumb.path),
['/work/root', '/work/root/src'],
)
// Above the workspace root: the Host confines the file listing to the
// workspace, so the level falls back to folders — which browse anywhere.
model.open('/work')
await settle()
assert.deepEqual(asked[1], ['folders', '/work'])
assert.equal(model.getSnapshot().listing.files, false)
})
test('a file listing that fails still leaves a browsable level', async () => {
const { module: mod } = await loadClient()
// The comparison the model makes is a prefix test on a path the Host may
// resolve differently — a symlinked session folder, say — so a refusal from
// the file listing is answered rather than shown: the folder listing runs.
const model = new mod.DirectoryPickerModel({
folders: () => Promise.resolve(listingFixture()),
files: () => Promise.reject(new Error('outside the workspace')),
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
assert.equal(model.getSnapshot().error, '')
assert.equal(model.getSnapshot().listing.files, false)
assert.equal(model.getSnapshot().listing.base, '/work/root/src')
})
test('a file row is taken as the path to add rather than walked into', async () => {
const { module: mod } = await loadClient()
// Both listers refuse a path that names a file, which is what the Host does:
// the file listing because a file has no children, the folder listing because
// the same. Only a level can be listed.
const isFile = (path) => /\.(md|ts)$/.test(String(path))
const refuseFile = (path, answer) =>
isFile(path) ? Promise.reject(new Error('not a directory')) : Promise.resolve(answer)
const model = new mod.DirectoryPickerModel({
folders: (path) => refuseFile(path, listingFixture()),
files: (sessionId, path) => refuseFile(path, workspaceFixture()),
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
const picked = []
const render = () =>
mod.DirectoryPicker({
model,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: (path) => picked.push(path),
})
const rowAt = (tree, path) =>
buttonsOf(tree)
.filter((button) => button.props.className === mod.ROW_CLASS)
.find((button) => button.props.title === path)
const fileRow = rowAt(render(), '/work/root/src/CONTROLLER.md')
assert.notEqual(fileRow, undefined, 'the file is a row in the listing')
assert.equal(fileRow.props.style.background, 'transparent', 'an unpicked file row is not marked')
// A folder row carries the folder glyph and a file row the file-type one, which
// is the only thing telling the two apart before the click.
const glyphOf = (row) => (row.children ?? [])[0].children?.[0]?.type
assert.equal(glyphOf(fileRow), 'FileTypeIcon')
assert.equal(glyphOf(rowAt(render(), '/work/root/src/shared')), 'IconFolderCloseRegular')
// Clicking it fills the field instead of listing it, and marks the row: a file
// is taken, not entered.
fileRow.props.onClick()
assert.equal(model.getSnapshot().draft, '/work/root/src/CONTROLLER.md')
assert.notEqual(rowAt(render(), '/work/root/src/CONTROLLER.md').props.style.background, 'transparent')
// Enter on a file the level lists asks for a listing of a file, which is not a
// failure: the level stays and nothing is reported.
model.open(model.getSnapshot().draft)
await settle()
assert.equal(model.getSnapshot().error, '')
assert.equal(model.getSnapshot().listing.base, '/work/root/src', 'the level survives it')
// The footer then adds exactly that, in the declared form.
const addButton = render().props.footer.find((entry) => (entry.children ?? [])[0] === 'Add')
addButton.props.onClick()
assert.deepEqual(picked, ['src/CONTROLLER.md'])
})
test('files are offered to the extras control only, and only where the Host has them', async () => {
const { module: mod } = await loadClient()
const browse = mod.directoryBrowse({
get: (name) => (name === 'remote.workspaceFiles' ? { list: () => {} } : undefined),
})
assert.equal(mod.fileLister(browse, 'extra'), browse.files.list, 'an extra can name a file')
// A focus is a folder, and file rows there would only offer something that
// cannot be chosen.
assert.equal(mod.fileLister(browse, 'focus'), undefined)
// No file service, or no browse face at all: the folder listing stands.
assert.equal(mod.fileLister(mod.directoryBrowse({ get: () => undefined }), 'extra'), undefined)
assert.equal(mod.fileLister({}, 'extra'), undefined)
assert.equal(mod.fileLister(undefined, 'extra'), undefined)
})
test('the picker says so when a level is folders only, and not when it is not', async () => { const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
folders: () => 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: () => {},
})
assert.ok(
textsOf(tree).some((text) => text.includes('Files are listed inside the session folder only')),
'a folders-only level explains itself',
)
// A level that does carry files needs no note about it.
const withFiles = new mod.DirectoryPickerModel({
folders: () => Promise.resolve(listingFixture()),
files: () => Promise.resolve(workspaceFixture()),
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
assert.equal(
textsOf(
mod.DirectoryPicker({
model: withFiles,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: () => {},
}),
).some((text) => text.includes('Files are listed inside the session folder only')),
false,
)
})
+125 -1
View File
@@ -170,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
@@ -195,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(
+58
View File
@@ -187,3 +187,61 @@ test('junk in a declaration is ignored rather than thrown on', () => {
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')
})