Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608b59639c | ||
|
|
c49acfdb2c | ||
|
|
a40040ec17 | ||
|
|
8ab3299694 | ||
|
|
a9a25490db | ||
|
|
b0902017fd | ||
|
|
6b92656ed2 | ||
|
|
5d06829118 | ||
|
|
ef3df2d939 |
No files matched your search
@@ -21,6 +21,24 @@ release notes. Write it for that reader:
|
|||||||
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
|
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
|
||||||
subject line is read; the body is not.
|
subject line is read; the body is not.
|
||||||
|
|
||||||
|
## Commit as the work lands
|
||||||
|
|
||||||
|
Commit each step as soon as it is finished rather than leaving it in the tree
|
||||||
|
until the end. Two reasons, and the second is the one that bites:
|
||||||
|
|
||||||
|
- An uncommitted change has no release note at all. The subject is the sentence a
|
||||||
|
user reads, so work that never becomes a commit never becomes a sentence.
|
||||||
|
- A tree carrying four unrelated changes cannot be split into four honest
|
||||||
|
subjects after the fact — the reasoning behind each one is only in the
|
||||||
|
transcript by then, and the range picks its bump from whatever subjects it
|
||||||
|
ends up with. One `feat:` batched in with a handful of fixes is a minor bump
|
||||||
|
for all of them.
|
||||||
|
|
||||||
|
Keep a subject to one change and the version follows from it.
|
||||||
|
|
||||||
|
A commit here is local. Pushing to `master` is what releases, so it stays a
|
||||||
|
separate, deliberate step.
|
||||||
|
|
||||||
## The release job owns the version and the changelog
|
## The release job owns the version and the changelog
|
||||||
|
|
||||||
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
|
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
|
||||||
|
|||||||
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
Every release, newest first, generated from the commits in each release.
|
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
|
||||||
|
|
||||||
|
- 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
|
## v0.2.0 — 2026-09-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
So an out-of-scope file call is put to you, and saying yes records the grant for
|
||||||
the rest of the conversation.
|
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
|
### The `facet` tool
|
||||||
|
|
||||||
| action | effect |
|
| 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
|
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.
|
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**,
|
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
|
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
|
the allowed paths, adds and removes them, and carries the mode details and usage
|
||||||
@@ -152,6 +170,36 @@ 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
|
package ships. Writes go to the profile patch, so reopening a chat gives back the
|
||||||
scope it had.
|
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 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
|
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
|
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
|
`danger-full-access` permission preset sets `approval: never` — refuses it before
|
||||||
@@ -196,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
|
- **`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
|
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
|
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** —
|
- **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
|
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.
|
tools whose path argument is a plain top-level string.
|
||||||
@@ -213,26 +264,32 @@ Honest boundaries, because each one is a decision rather than an oversight:
|
|||||||
|
|
||||||
Verified:
|
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
|
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
|
(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
|
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
|
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
|
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
|
narrowed past is dropped rather than honoured, that `reset` lands on the
|
||||||
declared scope rather than the session directory, and that every `facet` result
|
declared scope rather than the session directory, that a search naming no path
|
||||||
satisfies the tool's own declared output schema.
|
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
|
- The bundle's patch layers parse, merge and land correctly against the real
|
||||||
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
||||||
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
||||||
the `facet-guard` row and the compaction config, with `preset-standard`
|
the `facet-guard` row and the compaction config, with `preset-standard`
|
||||||
untouched. The optional `standard-compaction` overlay was checked the same way.
|
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
|
**Verified live.** The bundle is installed in a `web` profile and composing
|
||||||
harness, because that needs the bundle installed. The first thing to check after
|
sessions: the runtime binding is in the running session's context, the crosshair
|
||||||
installing is that `preset-facet` is not `broken` — a preset whose child row
|
panel writes the declaration, and the guard decides the calls it names. That is
|
||||||
fails to resolve still appears on the roster, with its diagnostic, and cannot
|
also where the `bash` boundary above was confirmed the hard way. The first thing
|
||||||
compose a session:
|
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
|
```sh
|
||||||
dsh --profile web --dump-config | grep -A 3 preset-facet
|
dsh --profile web --dump-config | grep -A 3 preset-facet
|
||||||
@@ -297,7 +354,7 @@ lib/scope.js the pure half — argument extraction, text, reason
|
|||||||
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
||||||
presets/facet.patch.yml the `facet` agent preset
|
presets/facet.patch.yml the `facet` agent preset
|
||||||
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
||||||
test/ 73 tests
|
test/ 93 tests
|
||||||
```
|
```
|
||||||
|
|
||||||
`client/client.js` is a browser half rather than a module the host imports, and
|
`client/client.js` is a browser half rather than a module the host imports, and
|
||||||
@@ -322,6 +379,28 @@ 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
|
the panel is the only writer, and a plugin that could not serve the namespace
|
||||||
would be a control that silently stored nothing.
|
would be a control that silently stored nothing.
|
||||||
|
|
||||||
|
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
|
||||||
|
its own — the widths and a row hover — and passes each card a class through
|
||||||
|
`Modal`'s `className`. Every selector in it doubles the class it names, so it
|
||||||
|
outranks the single hashed class the primitive applies whatever order the two
|
||||||
|
stylesheets land in; there is no `!important` and nothing reaching into a class
|
||||||
|
name this bundle does not own. The panel's body is a two-column grid that
|
||||||
|
`auto-fit` collapses to one on a narrow card, and each dialog scrolls a bounded
|
||||||
|
body rather than growing past the viewport — the panel used to be a tall single
|
||||||
|
column of prose on a 380px card, which is what it looked like.
|
||||||
|
|
||||||
## Also in this repository
|
## Also in this repository
|
||||||
|
|
||||||
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
||||||
|
|||||||
+983
-118
File diff suppressed because it is too large.
Load diff
+16
-1
@@ -37,6 +37,8 @@ import {
|
|||||||
FACET_TOOL,
|
FACET_TOOL,
|
||||||
facetContextText,
|
facetContextText,
|
||||||
facetRuleText,
|
facetRuleText,
|
||||||
|
IMPLICIT_SESSION_PATHS,
|
||||||
|
implicitSearchReason,
|
||||||
isWideningCall,
|
isWideningCall,
|
||||||
outsideReason,
|
outsideReason,
|
||||||
pathArgumentsFor,
|
pathArgumentsFor,
|
||||||
@@ -181,7 +183,20 @@ export function apply(ctx, config) {
|
|||||||
if (scope === undefined) return undefined
|
if (scope === undefined) return undefined
|
||||||
|
|
||||||
const paths = pathArgumentsFor(exec.name, exec.arguments)
|
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) {
|
for (const raw of paths) {
|
||||||
const verdict = await inside(scope, raw, exec.signal)
|
const verdict = await inside(scope, raw, exec.signal)
|
||||||
|
|||||||
+94
-7
@@ -97,6 +97,29 @@ export function createFacetPolicy(config) {
|
|||||||
const grants = new Map()
|
const grants = new Map()
|
||||||
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
|
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
|
||||||
const narrowed = new Map()
|
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.
|
* 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
|
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 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
|
* place — this one only has to notice a root that has since moved out from
|
||||||
* under it.
|
* 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|undefined} sessionId - the session.
|
||||||
* @param {string} sessionCwd - its resolved working directory.
|
* @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.
|
* @returns {string} the absolute facet root.
|
||||||
*/
|
*/
|
||||||
function rootFor(sessionId, sessionCwd) {
|
function rootFor(sessionId, sessionCwd, inherited) {
|
||||||
const declared = declaredScope(sessionId)?.focus
|
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)
|
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
|
||||||
if (agentFocus === undefined) return base
|
if (agentFocus !== undefined && within(agentFocus, base)) return agentFocus
|
||||||
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
|
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +208,13 @@ export function createFacetPolicy(config) {
|
|||||||
* resolve against. A declaration written relative to the session keeps the
|
* resolve against. A declaration written relative to the session keeps the
|
||||||
* same meaning whatever the session is focused on.
|
* 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.
|
* @returns {FacetScope|undefined} the scope.
|
||||||
*/
|
*/
|
||||||
function scopeFor(agent) {
|
function scopeFor(agent) {
|
||||||
@@ -170,9 +222,13 @@ export function createFacetPolicy(config) {
|
|||||||
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
|
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
|
||||||
const sessionCwd = resolve(cwd)
|
const sessionCwd = resolve(cwd)
|
||||||
const sessionId = idOf(agent)
|
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 {
|
return {
|
||||||
cwd: sessionCwd,
|
cwd: sessionCwd,
|
||||||
root: rootFor(sessionId, sessionCwd),
|
root,
|
||||||
extras: extrasFor(agent, sessionCwd),
|
extras: extrasFor(agent, sessionCwd),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,10 +340,22 @@ export function createFacetPolicy(config) {
|
|||||||
return [...(grants.get(sessionId) ?? [])]
|
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) {
|
function forget(sessionId) {
|
||||||
grants.delete(sessionId)
|
grants.delete(sessionId)
|
||||||
narrowed.delete(sessionId)
|
narrowed.delete(sessionId)
|
||||||
|
spawnedFrom.delete(sessionId)
|
||||||
|
if (![...spawnedFrom.values()].includes(sessionId)) roots.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -325,6 +393,25 @@ function isPath(value) {
|
|||||||
return typeof value === 'string' && value.trim() !== ''
|
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.
|
* The session id of a calling agent, when it has one.
|
||||||
*
|
*
|
||||||
|
|||||||
+49
-1
@@ -54,6 +54,28 @@ export const PATH_ARGUMENTS = Object.freeze({
|
|||||||
recall: ['path', 'focus'],
|
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. */
|
/** The tool this plugin registers, and the action on it that widens the scope. */
|
||||||
export const FACET_TOOL = 'facet'
|
export const FACET_TOOL = 'facet'
|
||||||
export const WIDENING_ACTION = 'add'
|
export const WIDENING_ACTION = 'add'
|
||||||
@@ -66,7 +88,10 @@ export const WIDENING_ACTION = 'add'
|
|||||||
* Anything that is not a non-empty string is skipped.
|
* Anything that is not a non-empty string is skipped.
|
||||||
*
|
*
|
||||||
* An empty result means "no opinion" — either the tool names no path (so it
|
* 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 {string} toolName - the tool being called.
|
||||||
* @param {unknown} args - the raw model arguments.
|
* @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.`
|
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.
|
* Why widening the scope is being put to the user.
|
||||||
*
|
*
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dsh-facet",
|
"name": "dsh-facet",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
|
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
|
||||||
@@ -50,7 +50,9 @@
|
|||||||
"inject": [
|
"inject": [
|
||||||
"@deepseek-ai/dsh-client-ui-settings",
|
"@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-conversation",
|
||||||
|
"@deepseek-ai/dsh-client-ui-workspace",
|
||||||
|
"@deepseek-ai/dsh-api-workspace-files"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+561
-20
@@ -35,6 +35,10 @@ async function loadClient() {
|
|||||||
SettingsForm: 'SettingsForm',
|
SettingsForm: 'SettingsForm',
|
||||||
SettingsValueField: 'SettingsValueField',
|
SettingsValueField: 'SettingsValueField',
|
||||||
SegmentedControl: 'SegmentedControl',
|
SegmentedControl: 'SegmentedControl',
|
||||||
|
// 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. */
|
/** Stands in for the real model: records the specs and answers with the value. */
|
||||||
SettingsFormModel: class {
|
SettingsFormModel: class {
|
||||||
constructor(scope, specs) {
|
constructor(scope, specs) {
|
||||||
@@ -84,14 +88,21 @@ async function loadClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
|
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
|
||||||
function drive(mod, { served = true, value = {} } = {}) {
|
function drive(mod, { served = true, value = {}, workspace } = {}) {
|
||||||
const seen = { formNs: undefined, watched: undefined, registrations: [] }
|
const seen = { formNs: undefined, watched: undefined, registrations: [] }
|
||||||
const disposers = []
|
const disposers = []
|
||||||
let currentSlot
|
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 = {
|
const ctx = {
|
||||||
|
get,
|
||||||
inject: (names, scope) => {
|
inject: (names, scope) => {
|
||||||
const scoped = {
|
const scoped = {
|
||||||
|
get,
|
||||||
configForms: {
|
configForms: {
|
||||||
get: (ns) => {
|
get: (ns) => {
|
||||||
seen.formNs = ns
|
seen.formNs = ns
|
||||||
@@ -170,8 +181,10 @@ test('the context button rides the composer tool row, gated on the same namespac
|
|||||||
assert.equal(button.options.id, 'facet-context')
|
assert.equal(button.options.id, 'facet-context')
|
||||||
assert.equal(typeof button.options.inject, 'function')
|
assert.equal(typeof button.options.inject, 'function')
|
||||||
// It has to receive the settings form, because the panel writes through the
|
// It has to receive the settings form, because the panel writes through the
|
||||||
// very same document the plugin card does.
|
// 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().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 () => {
|
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
|
||||||
@@ -291,6 +304,10 @@ test('the client half loads the packages whose slots it registers into', async (
|
|||||||
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-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-conversation'), 'registers into conversation.input.left')
|
||||||
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
|
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 ─────────────────────────────────────────────
|
// ── the context button and panel ─────────────────────────────────────────────
|
||||||
@@ -364,31 +381,32 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a
|
|||||||
mutate: (ops) => (written.push(ops), true),
|
mutate: (ops) => (written.push(ops), true),
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', mode: 'deny', onClose: () => {} })
|
const tree = mod.FacetContextPanel({
|
||||||
|
form,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
cwd: '/work/root',
|
||||||
|
browse: { available: () => true, list: () => Promise.resolve() },
|
||||||
|
onClose: () => {},
|
||||||
|
})
|
||||||
assert.equal(tree.type, 'Modal')
|
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.
|
// Walk the tree for the text and controls a user would see.
|
||||||
const texts = []
|
const texts = textsOf(tree)
|
||||||
const buttons = []
|
const buttons = buttonsOf(tree)
|
||||||
const inputs = []
|
const inputs = []
|
||||||
const walk = (node) => {
|
walkTree(tree, (node) => {
|
||||||
if (typeof node === 'string') return texts.push(node)
|
|
||||||
if (node === null || node === undefined || typeof node !== 'object') return
|
|
||||||
if (Array.isArray(node)) return node.forEach(walk)
|
|
||||||
if (node.type === 'button') buttons.push(node)
|
|
||||||
if (node.type === 'input') inputs.push(node)
|
if (node.type === 'input') inputs.push(node)
|
||||||
// Children arrive either as createElement arguments or, as the card does it,
|
})
|
||||||
// through the `children` prop.
|
|
||||||
if (Array.isArray(node.children)) node.children.forEach(walk)
|
|
||||||
walk(node.props?.children)
|
|
||||||
}
|
|
||||||
walk(tree)
|
|
||||||
|
|
||||||
const labelOf = (node) => (node.children ?? [])[0]
|
const labelOf = (node) => (node.children ?? [])[0]
|
||||||
|
|
||||||
const prose = texts.join('\n')
|
const prose = texts.join('\n')
|
||||||
assert.match(prose, /What this conversation may see/)
|
assert.match(prose, /What this conversation may see/)
|
||||||
// The two tabs the preset picker cannot show for a custom preset live here.
|
// 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, /Mode details/)
|
||||||
assert.match(prose, /How to use/)
|
assert.match(prose, /How to use/)
|
||||||
// The honest bit about this deployment, so the panel does not promise a
|
// The honest bit about this deployment, so the panel does not promise a
|
||||||
@@ -397,9 +415,532 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a
|
|||||||
assert.match(prose, /danger-full-access/)
|
assert.match(prose, /danger-full-access/)
|
||||||
|
|
||||||
// The declared scope is on screen, and the existing extra has its own control.
|
// The declared scope is on screen, and the existing extra has its own control.
|
||||||
assert.equal(inputs[0].props.value, 'src/room')
|
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)
|
assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1)
|
||||||
assert.ok(buttons.some((button) => labelOf(button) === 'Add'))
|
|
||||||
|
// 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) === 'Clear'))
|
||||||
assert.ok(buttons.some((button) => labelOf(button) === 'Set'))
|
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/)
|
||||||
|
// 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: (name) =>
|
||||||
|
({
|
||||||
|
uiWorkspace: {
|
||||||
|
listDirectory: (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, [['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 () => {
|
||||||
|
const { module: mod } = await loadClient()
|
||||||
|
|
||||||
|
const asked = []
|
||||||
|
const model = new mod.DirectoryPickerModel({
|
||||||
|
folders: (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.base, '/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: a listing can
|
||||||
|
// fail on a path this panel may still legitimately declare.
|
||||||
|
const failing = new mod.DirectoryPickerModel({
|
||||||
|
folders: () =>
|
||||||
|
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({
|
||||||
|
folders: () => 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({
|
||||||
|
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
@@ -170,13 +170,59 @@ test('a prefix is not a parent', async () => {
|
|||||||
assert.equal(decision.kind, 'ask')
|
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()
|
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() }), {
|
assert.deepEqual(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() }), {
|
||||||
kind: 'allow',
|
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 () => {
|
test('tools the guard cannot judge are left alone, deliberately', async () => {
|
||||||
const { captured } = harness()
|
const { captured } = harness()
|
||||||
// A shell command cannot be read reliably from argv, so the facet is enforced
|
// 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' })
|
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 () => {
|
test('a standing grant and a session grant both open the scope', async () => {
|
||||||
const standing = harness({ extraRoots: ['../../shared'] })
|
const standing = harness({ extraRoots: ['../../shared'] })
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|||||||
@@ -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.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok'])
|
||||||
assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w')
|
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')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user