From 28f20a8aacb921d73400cb51243a74303ba8bba2 Mon Sep 17 00:00:00 2001 From: NotExpectedYet Date: Sat, 26 Sep 2026 07:13:33 +0100 Subject: [PATCH] fix: show the settings card, and make an edit actually take effect `mode` and `extraRoots` carried no `volatile()` marker, so the framework's form projection dropped the whole entry from `settings.describe` and the plugin had no card under Plugins at all. The marker is what creates the card. It is also what turns each field into a reference rather than a value: the resolved config hands `apply` a `{get(), [write]}` per volatile field, which is how a settings write lands in a running plugin without re-applying it. So the policy and the guard now read both fields at the point of use instead of snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked because a prompt section cannot be re-ordered without a remount. A section is also registered once as a plain string, so the rule could not follow a live `mode` without freezing at load. The mode-specific sentence moved into the binding, which is a callback. --- README.md | 21 ++++++++++++-- index.js | 37 +++++++++++++++++++----- lib/guard.js | 35 ++++++++++++++++------- lib/policy.js | 70 ++++++++++++++++++++++++++++++++++++++++++---- lib/scope.js | 46 ++++++++++++++++++------------ test/guard.test.js | 32 +++++++++++++++++++-- test/index.test.js | 63 +++++++++++++++++++++++++++++++++++++++-- test/scope.test.js | 38 +++++++++++++++++-------- 8 files changed, 283 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index b1994a6..a02a09d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,23 @@ The host row's config is where this deployment's facts belong — the profile's - ../../shared # relative to each session's own directory ``` +Both fields are also on the plugin's card under **Plugins** in the Web UI, which +is the same layer reached a different way: a settings write rewrites this entry in +the profile patch rather than storing anything new. An edit there is live — the +guard reads `mode` and `extraRoots` on every call, so a change decides the next +file call without a restart. + +The one exception is `off`. Choosing it while a session is running stops the guard +immediately and empties the binding on the next request, because the binding is a +callback. The rule is not: it was registered at load as a plain string, and +nothing unregisters it, so the prompt would go on stating a rule that nothing +enforces. An `off` *deployment* is therefore settled at load — set it in the +patch. `ask` and `deny` swap freely in both directions. + +`sectionOrder` and `contextOrder` are deliberately absent from the card. They +place the rule and the binding among the other prompt sections, which is decided +once at load, and no section is re-ordered without a remount. + `extraRoots` is for code a module legitimately always depends on. Anything you grant by hand is per-conversation and is never persisted — "this session may also see `src/shared`" is a statement about one conversation, and writing it down @@ -171,7 +188,7 @@ Honest boundaries, because each one is a decision rather than an oversight: Verified: -- 42 tests (`npm test`) over the scope algebra, the policy state, and the guard — +- 45 tests (`npm test`) over the scope algebra, the policy state, and the guard — including that containment is decided on canonical targets (a symlink inside the facet pointing outside it is refused), that a prefix is not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool call never @@ -251,7 +268,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/ 42 tests +test/ 45 tests ``` ## Also in this repository diff --git a/index.js b/index.js index 7cc9cbf..35b3459 100644 --- a/index.js +++ b/index.js @@ -16,10 +16,12 @@ * published once here and consumed there, which is also why the preset can be * swapped without touching any of this. * - * There is deliberately no `settings` namespace. A facet grant is a statement - * about one conversation, so the only thing worth persisting is the deployment's - * standing grants, and those belong in the profile patch — the layer where this - * machine's facts already live. + * There is deliberately no `settings` *namespace* of our own: a facet grant is a + * statement about one conversation, so the only thing worth persisting is the + * deployment's standing grants, and those belong in the profile patch — the + * layer where this machine's facts already live. What the framework projects + * onto this row is the {@link Config} schema below, which is the same patch + * layer reached through the Web UI rather than a second place to store things. */ import Schema from '@deepseek-ai/schemastery' @@ -28,21 +30,42 @@ import { createFacetPolicy } from './lib/policy.js' export const name = 'dsh-facet' -/** Every configuration field this plugin exposes. */ +/** + * Every configuration field this plugin exposes. + * + * `mode` and `extraRoots` are marked `volatile()`, and that marker is doing two + * jobs rather than one. It is what puts them on the settings page at all — the + * framework's form projection keeps only fields under a volatile ancestor, and + * drops an entry with no such field from `settings.describe` entirely, so + * without it this row has no card. It is also what makes an edit *live*: a + * volatile field is handed to {@link apply} as a reference rather than a value, + * and the framework commits an edit into that reference without re-applying the + * plugin. + * + * The second job is why every reader of these two fields goes through + * `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin + * that takes the reference for a string gets a settings card that appears to + * work and changes nothing. + * + * `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place + * the rule and the binding among the other prompt sections, which is settled + * once at load; making them volatile would put two knobs nobody should touch on + * the card, and a section cannot be re-ordered without a remount anyway. + */ export const Config = Schema.object({ /** * How a call outside the facet is handled. `ask` questions the user, `deny` * refuses outright, `off` disables enforcement while leaving the preset in * place — useful for telling a scoping problem from an unrelated one. */ - mode: Schema.union(['ask', 'deny', 'off']).default('ask'), + mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(), /** * Paths always in scope, for code a module legitimately depends on — shared * types, a common test harness. Relative paths resolve against each session's * own directory, so the same list is meaningful in every workspace. */ - extraRoots: Schema.array(String).default([]), + extraRoots: Schema.array(String).default([]).volatile(), /** Placement of the invariant rule in the system prompt. */ sectionOrder: Schema.number().default(100), diff --git a/lib/guard.js b/lib/guard.js index 4278d1d..12404df 100644 --- a/lib/guard.js +++ b/lib/guard.js @@ -64,10 +64,19 @@ export const Config = Schema.object({ */ export function apply(ctx, config) { const policy = ctx.facetPolicy - /** @type {'ask'|'deny'|'off'} */ - const mode = policy.mode - if (mode === 'off') return + // Read at the point of use, never captured. `mode` is a volatile field, so a + // settings edit updates the policy's reference in place and deliberately does + // not re-apply this plugin; a `const mode` here would answer with the value + // the session was loaded with, for the life of the session. + const modeNow = () => policy.mode + + // `off` is the one part that stays a mount-time decision. Deciding it here + // means an `off` deployment contributes no rule, no binding and no tool — + // which is the point of the mode. The cost is that turning it *on* later needs + // a reload, because a volatile-only edit never re-runs this function. `ask` + // and `deny` swap freely in both directions. + if (modeNow() === 'off') return const sectionOrder = policy.sectionOrder ?? 100 const contextOrder = policy.contextOrder ?? 105 @@ -129,6 +138,9 @@ export function apply(ctx, config) { const classify = async (exec) => { if (exec.signal?.aborted) return undefined + const mode = modeNow() + if (mode === 'off') return undefined + // Widening is the one act that always needs the user, whatever the mode: // it is explicit, deliberate, and the only thing standing between a scoped // agent and an unscoped one. @@ -155,11 +167,13 @@ export function apply(ctx, config) { } // The rule. `interpolate: false` because every value in it is already - // literal; there is nothing here for the harness to substitute. + // literal; there is nothing here for the harness to substitute. It carries no + // `mode`, because a section is registered once and cannot follow a live edit — + // see `facetRuleText`. ctx.systemPrompt.section({ name: 'facet-scope', order: Number(sectionOrder), - text: facetRuleText({ mode }), + text: facetRuleText(), interpolate: false, }) @@ -173,11 +187,11 @@ export function apply(ctx, config) { text: (context) => { const scope = policy.scopeFor(context?.agent) if (scope === undefined) return '' - return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode }) + return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode: modeNow() }) }, }) - if (config.tool) ctx.tools.register(facetTool(ctx, policy, mode)) + if (config.tool) ctx.tools.register(facetTool(ctx, policy)) // `next()` first, so an earlier policy plugin keeps its say: this listener // only ever adds a question or a refusal to a call that was otherwise allowed. @@ -206,9 +220,8 @@ function requestedPaths(args) { * * @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context. * @param {object} policy - the `facetPolicy` service. - * @param {'ask'|'deny'|'off'} mode - the deployment's enforcement mode. */ -function facetTool(ctx, policy, mode) { +function facetTool(ctx, policy) { return defineTool({ name: FACET_TOOL, description: @@ -276,7 +289,9 @@ function facetTool(ctx, policy, mode) { ok: true, root: scope.root, facet: scope.root, - mode, + // Read here rather than captured at mount, so `status` reports the mode + // in force now instead of the one this session started under. + mode: String(policy.mode), standing: policy.standing, granted, scope: scope.extras, diff --git a/lib/policy.js b/lib/policy.js index b410a20..8cd71b0 100644 --- a/lib/policy.js +++ b/lib/policy.js @@ -18,6 +18,38 @@ import { isAbsolute, resolve } from 'node:path' +/** + * The symbol cosmokit uses to mark a volatile config reference. + * + * Looked up by symbol rather than by importing cosmokit, because the ESM and CJS + * copies of that library are distinct module instances and `Symbol.for` is the + * only identity they share. + */ +const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write') + +/** + * Read one configuration field. + * + * A `volatile()` field is a *reference* in the running config, not a plain + * value: the framework resolves the schema through `Schema.resolve`, which wraps + * each volatile field in `createVolatile(...)` — `{get(), [write]}`. A + * non-volatile field, or a config object a test handed straight to + * {@link createFacetPolicy}, is the value itself. Both are accepted, so one code + * path serves a real profile and a direct call. + * + * Every read has to happen at the point of use rather than once at construction. + * A settings write commits into these references in place and deliberately does + * *not* re-apply the plugin, so anything captured at construction would never + * see the change — which is how a plugin ends up with a settings card that does + * nothing. + * + * @param {unknown} value - a field of the resolved config. + * @returns {unknown} the current value. + */ +export function configValue(value) { + return typeof value === 'object' && value !== null && VOLATILE_WRITE in value ? value.get() : value +} + /** * @typedef {object} FacetScope * @property {string} cwd - the session working directory, absolute. @@ -28,6 +60,13 @@ import { isAbsolute, resolve } from 'node:path' /** * Build the policy for one deployment. * + * `config` is the *resolved* composition config, so its volatile fields are + * references rather than values; every read below goes through + * {@link configValue} and happens per use. Holding the config and reading it lazily + * is what makes the settings card honest: `mode` and `extraRoots` are edited live + * in the Web UI, and a policy that snapshotted them here would keep answering + * with the values it was loaded with. + * * @param {object} config * @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled. * @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd. @@ -36,7 +75,20 @@ import { isAbsolute, resolve } from 'node:path' export function createFacetPolicy(config) { /** @type {Map} session id → granted paths, in the order granted. */ const grants = new Map() - const standing = Array.isArray(config.extraRoots) ? config.extraRoots.filter(isPath) : [] + + /** + * The deployment's standing grants, read fresh on every call. + * + * Not filtered once at construction: `extraRoots` is volatile, so an edit in + * the settings UI replaces the reference's contents without remounting this + * plugin, and the next scope resolution is expected to see it. + * + * @returns {string[]} configured paths, blanks dropped. + */ + function standingRoots() { + const configured = configValue(config.extraRoots) + return Array.isArray(configured) ? configured.filter(isPath) : [] + } /** * The scope of one agent, or `undefined` when it has none. @@ -66,7 +118,7 @@ export function createFacetPolicy(config) { function extrasFor(agent, cwd) { const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined const own = sessionId === undefined ? [] : grants.get(sessionId) ?? [] - const all = [...standing, ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) + const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) return [...new Set(all)] } @@ -124,11 +176,17 @@ export function createFacetPolicy(config) { } return { - mode: config.mode, + /** The enforcement mode. A getter, so a settings edit is answered on the next call. */ + get mode() { + return configValue(config.mode) + }, /** Prompt placements, carried here so the preset row needs no configuration of its own. */ - sectionOrder: config.sectionOrder ?? 100, - contextOrder: config.contextOrder ?? 105, - standing: [...standing], + sectionOrder: configValue(config.sectionOrder) ?? 100, + contextOrder: configValue(config.contextOrder) ?? 105, + /** The standing grants. A getter for the same reason as {@link mode}. */ + get standing() { + return standingRoots() + }, scopeFor, grant, revoke, diff --git a/lib/scope.js b/lib/scope.js index 28ffc55..fe368c8 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -118,25 +118,24 @@ export function isWideningCall(toolName, args) { * treatment the sandbox policy and approval policy already get, for the same * reason. * - * @param {object} input - * @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled. - * @returns {string} prompt section text, or `''` to contribute nothing. + * It takes no configuration, and that is deliberate rather than an oversight. + * A system-prompt section is a plain string registered once; there is no + * callback to re-render it, and a settings write to a `volatile()` field + * updates the config reference *without* re-applying the plugin. So anything + * this function interpolated would freeze at whatever the value was at load — + * the prompt would go on describing the old `mode` while the guard enforced the + * new one. Everything that depends on `mode` therefore lives in the binding, + * which is a callback and therefore live. + * + * @returns {string} prompt section text. */ -export function facetRuleText(input) { - const { mode } = input - if (mode === 'off') return '' - - const reach = - mode === 'deny' - ? 'A call outside it is refused. `facet action=add` asks the user to grant a path, and is the only way to widen the scope.' - : 'A call outside it is not refused: it is put to the user as a question, so say in one line what you need and why. If they agree, record it with `facet action=add` so the rest of the session does not have to ask again.' - +export function facetRuleText() { return [ '# Facet scope', '', - 'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which.', + 'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which, and says how a call outside it is treated.', '', - `Work inside the facet. ${reach}`, + 'Work inside the facet.', '', 'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.', '', @@ -164,9 +163,12 @@ export function displayPath(cwd, path) { /** * The mutable half of the facet, for the runtime-context snapshot. * - * Kept to three short lines because it is re-sent as part of the conversation - * whenever it changes, and a scope statement that costs more than the scope is - * worth is a scope that does not pay for itself. + * Kept short because it is re-sent as part of the conversation whenever it + * changes, and a scope statement that costs more than the scope is worth is a + * scope that does not pay for itself. It carries one sentence more than the bare + * binding used to, because `mode` now belongs here: this is the only half that + * is re-rendered per request, so it is the only half that can follow a settings + * change. See {@link facetRuleText}. * * @param {object} input * @param {string} input.cwd - the session working directory. @@ -179,13 +181,21 @@ export function facetContextText(input) { const { cwd, root, extras = [], mode } = input if (mode === 'off' || typeof root !== 'string' || root === '') return '' + // Stated in terms of what the model should *do*, because the same sentence is + // what the user reads in an approval prompt. A denial that does not say how to + // proceed is a denial the agent works around with `bash`. + const reach = + mode === 'deny' + ? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope' + : 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again' + // The root is stated absolutely even though it is the session's own directory // and a relative form would be shorter. `facet: .` is true and useless: the // model needs a path it can reason about, and the user reading an approval // prompt needs to see which folder they are being asked about. const lines = [`facet: ${root}`] if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`) - lines.push(`outside the facet: ${mode === 'deny' ? 'refused' : 'ask the user'}`) + lines.push(`outside the facet: ${reach}`) return lines.join('\n') } diff --git a/test/guard.test.js b/test/guard.test.js index ac3fe5b..012f4ab 100644 --- a/test/guard.test.js +++ b/test/guard.test.js @@ -18,6 +18,15 @@ import { after, before, test } from 'node:test' import { apply as guard } from '../lib/guard.js' import { createFacetPolicy } from '../lib/policy.js' +import { Config } from '../index.js' + +/** + * The symbol cosmokit marks a volatile config reference with. A settings write + * reaches a running plugin as `target[write](value)` — that is literally what + * `updateVolatile` does — so a test can commit an edit the way the framework + * does, without a settings service. + */ +const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write') /** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */ const fsLikeLocal = { @@ -50,8 +59,8 @@ const fsLikeLocal = { } /** Capture what `apply` registered, so the test can drive it. */ -function harness({ mode = 'ask', extraRoots = [], tool = true } = {}) { - const policy = createFacetPolicy({ mode, extraRoots }) +function harness({ mode = 'ask', extraRoots = [], tool = true, config } = {}) { + const policy = createFacetPolicy(config ?? { mode, extraRoots }) const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined } const ctx = { facetPolicy: policy, @@ -217,6 +226,25 @@ test('off mode registers nothing', () => { assert.equal(captured.tool, undefined) }) +test('a live mode edit decides the next call, without the guard being remounted', async () => { + // The loader hands the guard the schema-resolved config, so `mode` arrives as + // a volatile reference rather than a string. This drives that exact shape. + const config = Config({ mode: 'ask' }) + const { captured } = harness({ config }) + const outside = { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() } + + assert.equal((await dispatch(captured, outside)).kind, 'ask') + + // What a settings write does: commit into the reference in place, and + // deliberately do not re-apply the plugin. One mount, three answers. + config.mode[VOLATILE_WRITE]('deny') + assert.equal((await dispatch(captured, outside)).kind, 'deny') + + // `off` silences the guard entirely, which is the whole meaning of the mode. + config.mode[VOLATILE_WRITE]('off') + assert.equal((await dispatch(captured, outside)).kind, 'allow') +}) + test('the guard adds to a decision, never overrides one', async () => { const { captured } = harness() const denied = await dispatch(captured, { diff --git a/test/index.test.js b/test/index.test.js index 3df3c43..de97679 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -10,6 +10,18 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { apply, Config } from '../index.js' +import { configValue } from '../lib/policy.js' + +/** + * The symbol cosmokit marks a volatile config reference with, and the writer it + * carries. A settings write reaches a running plugin through exactly this + * function — `updateVolatile(target, source)` is `target[write](source.get())` — + * so a test can commit an edit the same way the framework does. + */ +const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write') + +/** Whether a field arrived as a live reference rather than a plain value. */ +const isReference = (value) => typeof value === 'object' && value !== null && VOLATILE_WRITE in value /** The smallest context this row touches. */ function harness() { @@ -28,12 +40,59 @@ function harness() { test('the schema fills every default, so a bare row is a working row', () => { const config = Config({}) - assert.equal(config.mode, 'ask') - assert.deepEqual(config.extraRoots, []) + assert.equal(configValue(config.mode), 'ask') + assert.deepEqual(configValue(config.extraRoots), []) assert.equal(config.sectionOrder, 100) assert.equal(config.contextOrder, 105) }) +test('the editable fields are volatile, which is the only reason they have a settings card', () => { + const config = Config({}) + // The framework's form projection keeps only fields under a volatile ancestor + // and drops an entry with no such field from `settings.describe` entirely, so + // dropping either of these markers silently removes the card from the UI. + assert.equal(isReference(config.mode), true) + assert.equal(isReference(config.extraRoots), true) + + // Placement is settled once at load: not settings anyone edits live, and not + // on the card. + assert.equal(isReference(config.sectionOrder), false) + assert.equal(isReference(config.contextOrder), false) +}) + +test('a settings edit is answered on the next call, without the plugin being re-applied', () => { + const provided = [] + let applies = 0 + const config = Config({ mode: 'ask', extraRoots: ['/w/src/shared'] }) + apply( + { + provide: (name, value) => { + applies += 1 + provided.push({ name, value }) + }, + on: () => {}, + }, + config, + ) + const policy = provided[0].value + assert.equal(policy.mode, 'ask') + assert.deepEqual(policy.standing, ['/w/src/shared']) + + // What the framework does on a settings write: it commits into the running + // references and deliberately does NOT re-apply the plugin. Everything the + // policy reports has to follow from the reference alone. + config.mode[VOLATILE_WRITE]('deny') + config.extraRoots[VOLATILE_WRITE](['/w/src/shared', '/w/src/types']) + + assert.equal(applies, 1) + assert.equal(policy.mode, 'deny') + assert.deepEqual(policy.standing, ['/w/src/shared', '/w/src/types']) + assert.deepEqual( + policy.scopeFor({ session: { id: 's1', header: { cwd: '/w/src/room' } } }).extras, + ['/w/src/shared', '/w/src/types'], + ) +}) + test('a bad mode fails loudly at load rather than at the first file call', () => { assert.throws(() => Config({ mode: 'maybe' })) }) diff --git a/test/scope.test.js b/test/scope.test.js index 151cd6a..e41cade 100644 --- a/test/scope.test.js +++ b/test/scope.test.js @@ -51,33 +51,47 @@ test('isWideningCall is true only for the one act that needs the user', () => { }) test('facetRuleText states the rule and never the binding', () => { - const ask = facetRuleText({ mode: 'ask' }) - assert.match(ask, /put to the user as a question/) - // The two modes must not read alike: ask must not claim the call is refused. - assert.doesNotMatch(ask, /A call outside it is refused/) + const rule = facetRuleText() - const deny = facetRuleText({ mode: 'deny' }) - assert.match(deny, /A call outside it is refused/) + // Mode-neutral by construction. A prompt section is registered once as a plain + // string, and a `volatile()` edit updates the config reference without + // re-applying the plugin — so a `mode` interpolated here would freeze at load + // while the guard went on enforcing the new one. The mode-specific sentence + // lives in the binding, which is a callback. + assert.match(rule, /Work inside the facet\./) + assert.doesNotMatch(rule, /refused|put to the user as a question/) + assert.match(rule, /Never widen the scope on your own initiative/) // The rule must not name a path: a changing system prompt invalidates the // prompt cache for the whole conversation. - assert.doesNotMatch(ask, /\/|src\//) + assert.doesNotMatch(rule, /\/|src\//) - assert.equal(facetRuleText({ mode: 'off' }), '') assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '') }) test('facetContextText carries the binding that does change', () => { + const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }) assert.equal( - facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }), - 'facet: /w/src/room\noutside the facet: ask the user', + ask, + 'facet: /w/src/room\n' + + 'outside the facet: put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again', ) + + const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }) assert.equal( - facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }), - 'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused', + deny, + 'facet: /w/src/room\n' + + 'granted outside it: src/shared\n' + + 'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope', ) + + // The binding is the only half that can follow a live edit, so the two modes + // have to read differently here — they no longer differ in the rule. + assert.notEqual(ask, deny) + // Unscoped sessions contribute nothing rather than an empty heading. assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '') + assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '') }) test('displayPath prefers the shortest unambiguous form', () => {