feat: scope a session from a declaration the agent cannot widen
The facet root was the session's working directory and nothing else, so the only way to scope a conversation to a module was to make the module a workspace — and the only way to reach anything outside it was `extraRoots`, which is a fact about the deployment rather than about one conversation. Scope now has two layers, and the split is the point. The **user layer** is `config.scopes[sessionId]` — a `focus` and a list of `extras`, written only from the browser context panel through the settings service, which is what `settings.mutate` requiring a volatile path buys us. The **agent layer** is in memory: a narrowing `focus` and its grants. Both can only ever reduce what the user layer allows, so an agent that goes wrong cannot widen its own scope. `facet action=focus` is therefore the one scope change the agent may make unasked, bounded by the scope already in force and decided on canonical targets. `reset` clears the agent's side only, landing on the user's declaration rather than on the session directory — landing on the directory would have made `reset` a widening. `extraRoots` demotes to an optional deployment-wide seed. The binding now names the context panel as the way to widen, because it is: in a deployment with no approval channel, `facet action=add` cannot be granted at all.
This commit is contained in:
1 parent
f36d46efa1
commit
d9caf4eeec
8 files changed
+430
-51
No files matched your search
@@ -33,20 +33,25 @@ 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
|
* `mode`, `extraRoots` and `scopes` are marked `volatile()`, and that marker is
|
||||||
* jobs rather than one. It is what puts them on the settings page at all — the
|
* doing two jobs rather than one. It is what puts them on the settings page at
|
||||||
* framework's form projection keeps only fields under a volatile ancestor, and
|
* all — the framework's form projection keeps only fields under a volatile
|
||||||
* drops an entry with no such field from `settings.describe` entirely, so
|
* ancestor, and drops an entry with no such field from `settings.describe`
|
||||||
* without it this row has no card. It is also what makes an edit *live*: a
|
* entirely, so without it this row has no card. It is also what makes an edit
|
||||||
* volatile field is handed to {@link apply} as a reference rather than a value,
|
* *live*: a volatile field is handed to {@link apply} as a reference rather than
|
||||||
* and the framework commits an edit into that reference without re-applying the
|
* a value, and the framework commits an edit into that reference without
|
||||||
* plugin.
|
* re-applying the plugin.
|
||||||
*
|
*
|
||||||
* The second job is why every reader of these two fields goes through
|
* The second job is why every reader of these fields goes through
|
||||||
* `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin
|
* `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
|
* that takes the reference for a string gets a settings card that appears to
|
||||||
* work and changes nothing.
|
* work and changes nothing.
|
||||||
*
|
*
|
||||||
|
* `scopes` is volatile for a third reason: it is the only field the browser
|
||||||
|
* context panel writes, and `settings.mutate` refuses a path that is not under a
|
||||||
|
* volatile node. That is the whole mechanism by which the panel — and not the
|
||||||
|
* agent — decides what one conversation may see.
|
||||||
|
*
|
||||||
* `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place
|
* `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place
|
||||||
* the rule and the binding among the other prompt sections, which is settled
|
* 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
|
* once at load; making them volatile would put two knobs nobody should touch on
|
||||||
@@ -61,12 +66,46 @@ export const Config = Schema.object({
|
|||||||
mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(),
|
mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paths always in scope, for code a module legitimately depends on — shared
|
* A deployment-wide seed of paths always in scope, for code a module
|
||||||
* types, a common test harness. Relative paths resolve against each session's
|
* legitimately depends on — shared types, a common test harness.
|
||||||
* own directory, so the same list is meaningful in every workspace.
|
*
|
||||||
|
* Deliberately only a seed. Before per-session scope existed this was the only
|
||||||
|
* way to widen anything, which meant "this one conversation needs the entity
|
||||||
|
* folder" had to be written as a fact about every conversation in every
|
||||||
|
* workspace. Per-session scope belongs in {@link Config.scopes}; this is for
|
||||||
|
* the paths a deployment genuinely always wants, and it is empty by default.
|
||||||
|
*
|
||||||
|
* Relative paths resolve against each session's own directory, so the same
|
||||||
|
* list is meaningful in every workspace.
|
||||||
*/
|
*/
|
||||||
extraRoots: Schema.array(String).default([]).volatile(),
|
extraRoots: Schema.array(String).default([]).volatile(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session scope — the user's declaration for one conversation, keyed by
|
||||||
|
* session id.
|
||||||
|
*
|
||||||
|
* Written only by the browser context panel, through the settings service, and
|
||||||
|
* never by the agent: this plugin exposes no write path to it, and a facet
|
||||||
|
* session carries no settings tool. `focus` narrows the session to a folder;
|
||||||
|
* `extras` are paths the conversation may read outside it. Both are relative
|
||||||
|
* to the session's own directory unless absolute, so a declaration keeps its
|
||||||
|
* meaning wherever the workspace lives.
|
||||||
|
*
|
||||||
|
* Entries outlive their sessions, which is deliberate — reopening a chat
|
||||||
|
* should give back the scope it had — and costs a little growth in the
|
||||||
|
* profile patch that the panel can tidy.
|
||||||
|
*/
|
||||||
|
scopes: Schema.dict(
|
||||||
|
Schema.object({
|
||||||
|
/** The session's facet root, relative to the session directory or absolute. Empty means the session directory itself. */
|
||||||
|
focus: Schema.string(),
|
||||||
|
/** Paths this session may also read, outside the root. */
|
||||||
|
extras: Schema.array(String).default([]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default({})
|
||||||
|
.volatile(),
|
||||||
|
|
||||||
/** Placement of the invariant rule in the system prompt. */
|
/** Placement of the invariant rule in the system prompt. */
|
||||||
sectionOrder: Schema.number().default(100),
|
sectionOrder: Schema.number().default(100),
|
||||||
|
|
||||||
|
|||||||
+62
-10
@@ -246,13 +246,18 @@ function requestedPaths(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `facet` tool: read the scope, or ask to widen it.
|
* The `facet` tool: read the scope, narrow it, or ask to widen it.
|
||||||
*
|
*
|
||||||
* `add` does not enforce anything itself — the pre-execute listener has already
|
* `add` does not enforce anything itself — the pre-execute listener has already
|
||||||
* put the question to the user, and the body only runs if they said yes. That
|
* put the question to the user, and the body only runs if they said yes. That
|
||||||
* split is deliberate: the approval is a property of the *call*, so it holds
|
* split is deliberate: the approval is a property of the *call*, so it holds
|
||||||
* even if this tool is later replaced or dispatched from `run_code`.
|
* even if this tool is later replaced or dispatched from `run_code`.
|
||||||
*
|
*
|
||||||
|
* `focus` is the one action this tool performs without asking, and it is the
|
||||||
|
* only one that can only ever *reduce* the scope. That asymmetry is the whole
|
||||||
|
* design: an agent may decide a conversation is about one folder, and may not
|
||||||
|
* decide it is about more.
|
||||||
|
*
|
||||||
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
|
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
|
||||||
* @param {object} policy - the `facetPolicy` service.
|
* @param {object} policy - the `facetPolicy` service.
|
||||||
*/
|
*/
|
||||||
@@ -260,21 +265,23 @@ function facetTool(ctx, policy) {
|
|||||||
return defineTool({
|
return defineTool({
|
||||||
name: FACET_TOOL,
|
name: FACET_TOOL,
|
||||||
description:
|
description:
|
||||||
'Read this session\u2019s facet scope, or ask the user to grant an extra path. A facet is the folder the ' +
|
'Read this session\u2019s facet scope, narrow it to one folder, or ask the user for an extra path. A facet is ' +
|
||||||
'conversation was started in; the session is scoped to it. Use `status` to see the current scope. Use `add` ' +
|
'the folder the conversation is scoped to; `status` reports it. Use `focus` when the work has settled on one ' +
|
||||||
'only when you actually need a file outside the facet — it asks the user for permission, and the grant lasts ' +
|
'folder inside the facet — narrowing is always allowed and takes effect at once. Use `add` only when you ' +
|
||||||
'for the rest of the conversation. Never call `add` for a path you merely expect to need.',
|
'actually need a file outside the facet: it asks the user for permission, and the grant lasts for the rest of ' +
|
||||||
|
'the conversation. Never call `add` for a path you merely expect to need. Where the deployment has no approval ' +
|
||||||
|
'channel, `add` cannot be granted at all and the user widens the scope from the facet panel in their browser.',
|
||||||
parameters: {
|
parameters: {
|
||||||
action: {
|
action: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: true,
|
required: true,
|
||||||
description: 'One of: status, add, remove, reset.',
|
description: 'One of: status, focus, add, remove, reset.',
|
||||||
},
|
},
|
||||||
paths: {
|
paths: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
description:
|
description:
|
||||||
'Paths to grant, relative to the session directory or absolute. Required for add and remove; ignored otherwise.',
|
'Paths, relative to the session directory or absolute. Required for add and remove; for focus, exactly one folder inside the current facet.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
@@ -307,13 +314,16 @@ function facetTool(ctx, policy) {
|
|||||||
const problem =
|
const problem =
|
||||||
(action === 'add' || action === 'remove') && requested.length === 0
|
(action === 'add' || action === 'remove') && requested.length === 0
|
||||||
? `facet: action "${action}" needs \`paths\``
|
? `facet: action "${action}" needs \`paths\``
|
||||||
: !['status', 'add', 'remove', 'reset'].includes(action)
|
: action === 'focus' && requested.length !== 1
|
||||||
? `facet: unknown action "${action}"; expected status, add, remove or reset`
|
? 'facet: action "focus" needs exactly one folder'
|
||||||
|
: !['status', 'focus', 'add', 'remove', 'reset'].includes(action)
|
||||||
|
? `facet: unknown action "${action}"; expected status, focus, add, remove or reset`
|
||||||
: undefined
|
: undefined
|
||||||
if (problem !== undefined) throw new Error(problem)
|
if (problem !== undefined) throw new Error(problem)
|
||||||
|
|
||||||
const before = policy.grantsFor(sessionId)
|
const before = policy.grantsFor(sessionId)
|
||||||
if (action === 'add') policy.grant(sessionId, requested, cwd)
|
if (action === 'focus') policy.narrowTo(sessionId, await narrowing(ctx, policy, exec, requested[0]))
|
||||||
|
else if (action === 'add') policy.grant(sessionId, requested, cwd)
|
||||||
else if (action === 'remove') policy.revoke(sessionId, requested, cwd)
|
else if (action === 'remove') policy.revoke(sessionId, requested, cwd)
|
||||||
else if (action === 'reset') policy.reset(sessionId)
|
else if (action === 'reset') policy.reset(sessionId)
|
||||||
|
|
||||||
@@ -340,6 +350,48 @@ function facetTool(ctx, policy) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one `focus` request to the canonical root it asks for.
|
||||||
|
*
|
||||||
|
* Refuses anything the scope already in force does not contain, because that
|
||||||
|
* would be a widening by another name — and widening is the user's decision, not
|
||||||
|
* the agent's. The containment test is the filesystem provider's, on canonical
|
||||||
|
* targets, for the same reason the guard uses it: a symlink inside the facet
|
||||||
|
* that points outside it must not become a way through.
|
||||||
|
*
|
||||||
|
* The canonical key is what gets recorded, so a later `scopeFor` compares like
|
||||||
|
* with like. Where the user declared a root through a symlink, the two spellings
|
||||||
|
* may not share a prefix; the narrowing is then dropped rather than trusted,
|
||||||
|
* which loses the narrowing and never the boundary.
|
||||||
|
*
|
||||||
|
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context, holding `fs`.
|
||||||
|
* @param {object} policy - the `facetPolicy` service.
|
||||||
|
* @param {import('@deepseek-ai/dsh-tools').ToolExecution} exec - the running call.
|
||||||
|
* @param {string} raw - the folder the model named.
|
||||||
|
* @returns {Promise<string>} the absolute root to narrow to.
|
||||||
|
*/
|
||||||
|
async function narrowing(ctx, policy, exec, raw) {
|
||||||
|
const scope = policy.scopeFor(exec.agent)
|
||||||
|
if (scope === undefined) throw new Error('facet: this session has no working directory, so there is nothing to narrow')
|
||||||
|
|
||||||
|
let target
|
||||||
|
let boundary
|
||||||
|
try {
|
||||||
|
target = await ctx.fs.resolve(raw, { cwd: scope.cwd, signal: exec.signal })
|
||||||
|
boundary = await ctx.fs.resolve(scope.root, { cwd: scope.cwd, signal: exec.signal })
|
||||||
|
} catch {
|
||||||
|
throw new Error(`facet: cannot resolve "${raw}", so the facet cannot be narrowed to it`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.targetKey !== boundary.targetKey && !ctx.fs.contains(boundary, target)) {
|
||||||
|
throw new Error(
|
||||||
|
`facet: "${raw}" is outside the current facet (${scope.root}). Narrowing cannot leave the scope already in force; ` +
|
||||||
|
'ask the user to widen it from the facet panel if the work really has moved.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return target.targetKey
|
||||||
|
}
|
||||||
|
|
||||||
/** Model-facing text for one `facet` result. Pure. */
|
/** Model-facing text for one `facet` result. Pure. */
|
||||||
function renderFacet(value) {
|
function renderFacet(value) {
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
+142
-12
@@ -16,7 +16,7 @@
|
|||||||
* exists to prevent.
|
* exists to prevent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isAbsolute, resolve } from 'node:path'
|
import { isAbsolute, resolve, sep } from 'node:path'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The symbol cosmokit uses to mark a volatile config reference.
|
* The symbol cosmokit uses to mark a volatile config reference.
|
||||||
@@ -63,18 +63,78 @@ export function configValue(value) {
|
|||||||
* `config` is the *resolved* composition config, so its volatile fields are
|
* `config` is the *resolved* composition config, so its volatile fields are
|
||||||
* references rather than values; every read below goes through
|
* references rather than values; every read below goes through
|
||||||
* {@link configValue} and happens per use. Holding the config and reading it lazily
|
* {@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
|
* is what makes the settings card honest: `mode`, `extraRoots` and `scopes` are
|
||||||
* in the Web UI, and a policy that snapshotted them here would keep answering
|
* edited live in the Web UI, and a policy that snapshotted them here would keep
|
||||||
* with the values it was loaded with.
|
* answering with the values it was loaded with.
|
||||||
|
*
|
||||||
|
* ## Two layers of scope, and only one of them the agent can move
|
||||||
|
*
|
||||||
|
* The session's scope is declared in two places on purpose, because the two
|
||||||
|
* audiences have opposite authority:
|
||||||
|
*
|
||||||
|
* * the **user layer** is `config.scopes[sessionId]` — a `focus` and a list of
|
||||||
|
* `extras`, written only from the browser context panel through the settings
|
||||||
|
* service. The agent has no write path to it: nothing in this plugin exposes
|
||||||
|
* one, and a facet session carries no settings tool.
|
||||||
|
* * the **agent layer** is in memory — a narrowing `focus` override and the
|
||||||
|
* grants it asked for. Both can only ever *reduce* what the user layer
|
||||||
|
* allows, so an agent that goes wrong cannot widen its own scope.
|
||||||
|
*
|
||||||
|
* The effective root is the agent's narrowing while it still sits inside the
|
||||||
|
* user's root, and the user's root otherwise. That ordering matters when the
|
||||||
|
* user narrows after the agent has: the stale agent focus is dropped rather than
|
||||||
|
* honoured, because honouring it would put the session outside the scope the
|
||||||
|
* user had just declared.
|
||||||
*
|
*
|
||||||
* @param {object} config
|
* @param {object} config
|
||||||
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
|
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
|
||||||
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
|
* @param {readonly string[]} [config.extraRoots] - a deployment-wide seed of standing grants.
|
||||||
|
* @param {Record<string, {focus?: string, extras?: readonly string[]}>} [config.scopes] - per-session scope, keyed by session id.
|
||||||
* @returns {object} the `facetPolicy` service implementation.
|
* @returns {object} the `facetPolicy` service implementation.
|
||||||
*/
|
*/
|
||||||
export function createFacetPolicy(config) {
|
export function createFacetPolicy(config) {
|
||||||
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
|
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
|
||||||
const grants = new Map()
|
const grants = new Map()
|
||||||
|
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
|
||||||
|
const narrowed = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the user declared for one session, read fresh.
|
||||||
|
*
|
||||||
|
* @param {string|undefined} sessionId - the session to read.
|
||||||
|
* @returns {{focus?: string, extras?: readonly string[]}|undefined} the declaration.
|
||||||
|
*/
|
||||||
|
function declaredScope(sessionId) {
|
||||||
|
if (sessionId === undefined) return undefined
|
||||||
|
const all = configValue(config.scopes)
|
||||||
|
if (all === null || typeof all !== 'object') return undefined
|
||||||
|
const entry = all[sessionId]
|
||||||
|
return entry !== null && typeof entry === 'object' ? entry : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root in force for one session.
|
||||||
|
*
|
||||||
|
* The user's `focus` is resolved against the session directory, not against
|
||||||
|
* the process, so a relative declaration keeps its meaning if the workspace
|
||||||
|
* moves. The agent's narrowing is applied only while it is still inside that
|
||||||
|
* root; a plain prefix test is enough here because the guard's own containment
|
||||||
|
* check, which resolves symlinks, is what authorises a narrowing in the first
|
||||||
|
* place — this one only has to notice a root that has since moved out from
|
||||||
|
* under it.
|
||||||
|
*
|
||||||
|
* @param {string|undefined} sessionId - the session.
|
||||||
|
* @param {string} sessionCwd - its resolved working directory.
|
||||||
|
* @returns {string} the absolute facet root.
|
||||||
|
*/
|
||||||
|
function rootFor(sessionId, sessionCwd) {
|
||||||
|
const declared = declaredScope(sessionId)?.focus
|
||||||
|
const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd
|
||||||
|
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
|
||||||
|
if (agentFocus === undefined) return base
|
||||||
|
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deployment's standing grants, read fresh on every call.
|
* The deployment's standing grants, read fresh on every call.
|
||||||
@@ -97,31 +157,77 @@ export function createFacetPolicy(config) {
|
|||||||
* something arbitrary: `undefined` here means the guard has no opinion, which
|
* something arbitrary: `undefined` here means the guard has no opinion, which
|
||||||
* is the only safe reading of "we do not know where this agent is".
|
* is the only safe reading of "we do not know where this agent is".
|
||||||
*
|
*
|
||||||
|
* `cwd` stays the session directory even when the root is narrower, because
|
||||||
|
* it is what relative paths — a grant, a `focus`, an `extraRoots` entry —
|
||||||
|
* resolve against. A declaration written relative to the session keeps the
|
||||||
|
* same meaning whatever the session is focused on.
|
||||||
|
*
|
||||||
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
|
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
|
||||||
* @returns {FacetScope|undefined} the scope.
|
* @returns {FacetScope|undefined} the scope.
|
||||||
*/
|
*/
|
||||||
function scopeFor(agent) {
|
function scopeFor(agent) {
|
||||||
const cwd = agent?.session?.header?.cwd
|
const cwd = agent?.session?.header?.cwd
|
||||||
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
|
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
|
||||||
const root = resolve(cwd)
|
const sessionCwd = resolve(cwd)
|
||||||
return { cwd: root, root, extras: extrasFor(agent, root) }
|
const sessionId = idOf(agent)
|
||||||
|
return {
|
||||||
|
cwd: sessionCwd,
|
||||||
|
root: rootFor(sessionId, sessionCwd),
|
||||||
|
extras: extrasFor(agent, sessionCwd),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every path in scope beyond the root: standing grants first, then this
|
* Every path in scope beyond the root: the deployment seed, then what the user
|
||||||
* session's, deduplicated so the prompt does not repeat itself.
|
* declared for this session, then what the agent was granted — deduplicated so
|
||||||
|
* the prompt does not repeat itself.
|
||||||
|
*
|
||||||
|
* All three are kept rather than layered, because they answer different
|
||||||
|
* questions. The seed is "this deployment always needs it", the declaration is
|
||||||
|
* "this conversation needs it", and a grant is "the user allowed this call".
|
||||||
|
* Removing one from the list when a narrower one exists would mean a user
|
||||||
|
* editing their panel could silently drop a path the deployment depends on.
|
||||||
*
|
*
|
||||||
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
|
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
|
||||||
* @param {string} cwd - the resolved session directory.
|
* @param {string} cwd - the resolved session directory.
|
||||||
* @returns {string[]} absolute paths.
|
* @returns {string[]} absolute paths.
|
||||||
*/
|
*/
|
||||||
function extrasFor(agent, cwd) {
|
function extrasFor(agent, cwd) {
|
||||||
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
|
const sessionId = idOf(agent)
|
||||||
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
|
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
|
||||||
const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
|
const declared = declaredScope(sessionId)?.extras
|
||||||
|
const listed = Array.isArray(declared) ? declared.filter(isPath) : []
|
||||||
|
const all = [...standingRoots(), ...listed, ...own].map((path) =>
|
||||||
|
isAbsolute(path) ? resolve(path) : resolve(cwd, path),
|
||||||
|
)
|
||||||
return [...new Set(all)]
|
return [...new Set(all)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow one session's facet to a path the caller has already authorised.
|
||||||
|
*
|
||||||
|
* The containment check is the caller's, and deliberately so: only the guard
|
||||||
|
* holds `ctx.fs`, and deciding containment on canonical targets is the whole
|
||||||
|
* reason a symlink inside the facet does not open it. This records the answer;
|
||||||
|
* {@link rootFor} re-checks that the user has not since narrowed past it.
|
||||||
|
*
|
||||||
|
* @param {string} sessionId - the session being narrowed.
|
||||||
|
* @param {string} absolute - the absolute root to narrow to.
|
||||||
|
* @returns {string} the recorded focus.
|
||||||
|
*/
|
||||||
|
function narrowTo(sessionId, absolute) {
|
||||||
|
narrowed.set(sessionId, absolute)
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} sessionId - the session to read.
|
||||||
|
* @returns {string|undefined} the agent's narrowing for it, if any.
|
||||||
|
*/
|
||||||
|
function narrowFor(sessionId) {
|
||||||
|
return narrowed.get(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} sessionId - the session being widened.
|
* @param {string} sessionId - the session being widened.
|
||||||
* @param {readonly string[]} paths - raw paths, resolved against `cwd`.
|
* @param {readonly string[]} paths - raw paths, resolved against `cwd`.
|
||||||
@@ -154,11 +260,19 @@ export function createFacetPolicy(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Clear one session's agent-side scope: its grants and any narrowing it asked
|
||||||
|
* for.
|
||||||
|
*
|
||||||
|
* Deliberately not a widening. The user's declaration is not touched, so this
|
||||||
|
* returns the session to what the user set up in the panel — which may be
|
||||||
|
* narrower than where the agent had got to, and can never be wider.
|
||||||
|
*
|
||||||
* @param {string} sessionId - the session to clear.
|
* @param {string} sessionId - the session to clear.
|
||||||
* @returns {string[]} always empty.
|
* @returns {string[]} always empty.
|
||||||
*/
|
*/
|
||||||
function reset(sessionId) {
|
function reset(sessionId) {
|
||||||
grants.delete(sessionId)
|
grants.delete(sessionId)
|
||||||
|
narrowed.delete(sessionId)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +287,7 @@ export function createFacetPolicy(config) {
|
|||||||
/** Drop a finished session's grants. Called on `session/disposed`. */
|
/** Drop a finished session's grants. Called on `session/disposed`. */
|
||||||
function forget(sessionId) {
|
function forget(sessionId) {
|
||||||
grants.delete(sessionId)
|
grants.delete(sessionId)
|
||||||
|
narrowed.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -183,11 +298,16 @@ export function createFacetPolicy(config) {
|
|||||||
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
|
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
|
||||||
sectionOrder: configValue(config.sectionOrder) ?? 100,
|
sectionOrder: configValue(config.sectionOrder) ?? 100,
|
||||||
contextOrder: configValue(config.contextOrder) ?? 105,
|
contextOrder: configValue(config.contextOrder) ?? 105,
|
||||||
/** The standing grants. A getter for the same reason as {@link mode}. */
|
/** The deployment-wide seed of standing grants. A getter for the same reason as {@link mode}. */
|
||||||
get standing() {
|
get standing() {
|
||||||
return standingRoots()
|
return standingRoots()
|
||||||
},
|
},
|
||||||
|
/** What the user declared for one session: their `focus` and `extras`. */
|
||||||
|
declaredFor: declaredScope,
|
||||||
scopeFor,
|
scopeFor,
|
||||||
|
rootFor,
|
||||||
|
narrowTo,
|
||||||
|
narrowFor,
|
||||||
grant,
|
grant,
|
||||||
revoke,
|
revoke,
|
||||||
reset,
|
reset,
|
||||||
@@ -204,3 +324,13 @@ export function createFacetPolicy(config) {
|
|||||||
function isPath(value) {
|
function isPath(value) {
|
||||||
return typeof value === 'string' && value.trim() !== ''
|
return typeof value === 'string' && value.trim() !== ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session id of a calling agent, when it has one.
|
||||||
|
*
|
||||||
|
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
|
||||||
|
* @returns {string|undefined} the id.
|
||||||
|
*/
|
||||||
|
function idOf(agent) {
|
||||||
|
return typeof agent?.session?.id === 'string' ? agent.session.id : undefined
|
||||||
|
}
|
||||||
+4
-4
@@ -195,12 +195,12 @@ export function facetContextText(input) {
|
|||||||
// the reason this guard writes never reaches it: `serviceAsk` replaces a
|
// the reason this guard writes never reaches it: `serviceAsk` replaces a
|
||||||
// rejected ask with `the user rejected tool "…"`. So the binding is the one
|
// rejected ask with `the user rejected tool "…"`. So the binding is the one
|
||||||
// channel that can say what is true, and it names the mechanism that still
|
// channel that can say what is true, and it names the mechanism that still
|
||||||
// works — `extraRoots` is configuration applied by the profile, not a grant
|
// works: the facet's context panel, which the user owns and the agent has no
|
||||||
// asked for per conversation.
|
// write path to.
|
||||||
const reach = !canAsk
|
const reach = !canAsk
|
||||||
? 'refused — approval prompts are off in this session, so `facet action=add` cannot be approved either; only the user can widen this, by adding the path to `extraRoots`'
|
? 'refused — this session has no approval channel, so `facet action=add` cannot be granted; ask the user to widen the facet from its context panel'
|
||||||
: mode === 'deny'
|
: mode === 'deny'
|
||||||
? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
|
? 'refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted'
|
||||||
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
|
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
|
||||||
|
|
||||||
// The root is stated absolutely even though it is the session's own directory
|
// The root is stated absolutely even though it is the session's own directory
|
||||||
|
|||||||
+10
-2
@@ -160,14 +160,22 @@ test('nothing is registered while the Host does not serve the namespace', async
|
|||||||
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
|
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the card stages exactly the fields the host marks volatile', async () => {
|
test('the card stages exactly the deployment fields the host marks volatile', async () => {
|
||||||
const { module: mod, staged } = await loadClient()
|
const { module: mod, staged } = await loadClient()
|
||||||
drive(mod)
|
drive(mod)
|
||||||
|
|
||||||
const declared = Object.entries(Config.dict)
|
const volatileFields = Object.entries(Config.dict)
|
||||||
.filter(([, schema]) => schema.meta.volatile === true)
|
.filter(([, schema]) => schema.meta.volatile === true)
|
||||||
.map(([key]) => key)
|
.map(([key]) => key)
|
||||||
|
|
||||||
|
// `scopes` is volatile too, but it is not a deployment field and must not be a
|
||||||
|
// control on the card: it is per-session state, written by the context panel
|
||||||
|
// and keyed by session id. Staging it here would render a dictionary blob a
|
||||||
|
// user could not sensibly edit, and would put the panel's store on the one
|
||||||
|
// surface that edits the deployment.
|
||||||
|
assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it')
|
||||||
|
const declared = volatileFields.filter((key) => key !== 'scopes')
|
||||||
|
|
||||||
// A field served but never staged is invisible in the UI; a field staged but
|
// A field served but never staged is invisible in the UI; a field staged but
|
||||||
// never served makes `whileServed` withhold the whole card.
|
// never served makes `whileServed` withhold the whole card.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|||||||
+61
-5
@@ -107,6 +107,7 @@ before(async () => {
|
|||||||
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
|
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
|
||||||
facet = join(root, 'src', 'controllers', 'room')
|
facet = join(root, 'src', 'controllers', 'room')
|
||||||
await mkdir(facet, { recursive: true })
|
await mkdir(facet, { recursive: true })
|
||||||
|
await mkdir(join(facet, 'inner'), { recursive: true })
|
||||||
await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true })
|
await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true })
|
||||||
await mkdir(join(root, 'src', 'shared'), { recursive: true })
|
await mkdir(join(root, 'src', 'shared'), { recursive: true })
|
||||||
await writeFile(join(facet, 'index.ts'), 'export {}\n')
|
await writeFile(join(facet, 'index.ts'), 'export {}\n')
|
||||||
@@ -293,9 +294,10 @@ test('the binding refuses honestly when the session has no approval channel', ()
|
|||||||
const { captured } = harness({ mode: 'ask', approval: 'never' })
|
const { captured } = harness({ mode: 'ask', approval: 'never' })
|
||||||
const text = captured.context.text({ agent: agent() })
|
const text = captured.context.text({ agent: agent() })
|
||||||
|
|
||||||
assert.match(text, /approval prompts are off in this session/)
|
assert.match(text, /no approval channel/)
|
||||||
assert.match(text, /cannot be approved either/)
|
assert.match(text, /cannot be granted/)
|
||||||
assert.match(text, /`extraRoots`/)
|
// It names the mechanism that still works, which is the panel the user owns.
|
||||||
|
assert.match(text, /context panel/)
|
||||||
|
|
||||||
// The promise `ask` makes cannot be kept here, so it must not be made.
|
// The promise `ask` makes cannot be kept here, so it must not be made.
|
||||||
assert.doesNotMatch(text, /put to the user as a question/)
|
assert.doesNotMatch(text, /put to the user as a question/)
|
||||||
@@ -308,14 +310,14 @@ test('an approval channel keeps the promise, and a missing service counts as non
|
|||||||
// No approval service at all: the tool pipeline denies an ask it cannot route,
|
// No approval service at all: the tool pipeline denies an ask it cannot route,
|
||||||
// so the binding must not offer one.
|
// so the binding must not offer one.
|
||||||
const without = harness({ mode: 'ask' })
|
const without = harness({ mode: 'ask' })
|
||||||
assert.match(without.captured.context.text({ agent: agent() }), /approval prompts are off in this session/)
|
assert.match(without.captured.context.text({ agent: agent() }), /no approval channel/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deny mode still names the way that works when there is no channel', () => {
|
test('deny mode still names the way that works when there is no channel', () => {
|
||||||
const { captured } = harness({ mode: 'deny', approval: 'never' })
|
const { captured } = harness({ mode: 'deny', approval: 'never' })
|
||||||
const text = captured.context.text({ agent: agent() })
|
const text = captured.context.text({ agent: agent() })
|
||||||
assert.match(text, /refused/)
|
assert.match(text, /refused/)
|
||||||
assert.match(text, /`extraRoots`/)
|
assert.match(text, /context panel/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the facet tool reports the scope', async () => {
|
test('the facet tool reports the scope', async () => {
|
||||||
@@ -347,6 +349,60 @@ test('the facet tool grants, narrows and clears', async () => {
|
|||||||
assert.deepEqual(policy.grantsFor('session-1'), [])
|
assert.deepEqual(policy.grantsFor('session-1'), [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the facet tool narrows inside the facet and refuses a folder outside it', async () => {
|
||||||
|
const { captured, policy } = harness()
|
||||||
|
const exec = { agent: agent() }
|
||||||
|
|
||||||
|
// Narrowing is the one scope change the agent may make unasked: it can only
|
||||||
|
// ever reduce what is visible, so there is nothing for a user to approve.
|
||||||
|
const narrowed = await captured.tool.execute({ action: 'focus', paths: ['inner'] }, exec)
|
||||||
|
assert.equal(narrowed.root, join(facet, 'inner'))
|
||||||
|
assert.equal(narrowed.facet, join(facet, 'inner'))
|
||||||
|
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
|
||||||
|
|
||||||
|
// A folder outside the scope already in force is a widening by another name,
|
||||||
|
// and widening is the user's decision, taken in their panel.
|
||||||
|
await assert.rejects(
|
||||||
|
() => captured.tool.execute({ action: 'focus', paths: ['../../shared'] }, exec),
|
||||||
|
/outside the current facet/,
|
||||||
|
)
|
||||||
|
await assert.rejects(
|
||||||
|
() => captured.tool.execute({ action: 'focus', paths: ['../other'] }, exec),
|
||||||
|
/outside the current facet/,
|
||||||
|
)
|
||||||
|
// A refused narrowing must not have moved anything.
|
||||||
|
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the facet tool needs exactly one folder to focus on', async () => {
|
||||||
|
const { captured } = harness()
|
||||||
|
const exec = { agent: agent() }
|
||||||
|
await assert.rejects(() => captured.tool.execute({ action: 'focus' }, exec), /exactly one folder/)
|
||||||
|
await assert.rejects(
|
||||||
|
() => captured.tool.execute({ action: 'focus', paths: ['inner', '../other'] }, exec),
|
||||||
|
/exactly one folder/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reset lands on the scope the user declared, never back on the session directory', async () => {
|
||||||
|
const { captured, policy } = harness({
|
||||||
|
config: { mode: 'deny', scopes: { 'session-1': { focus: 'src/controllers/room' } } },
|
||||||
|
})
|
||||||
|
const exec = { agent: agent(root) }
|
||||||
|
|
||||||
|
// The user declared this conversation to be about the room controller, so the
|
||||||
|
// session is already narrower than the directory it was started in.
|
||||||
|
assert.equal(policy.scopeFor(agent(root)).root, facet)
|
||||||
|
|
||||||
|
await captured.tool.execute({ action: 'focus', paths: ['src/controllers/room/inner'] }, exec)
|
||||||
|
assert.equal(policy.scopeFor(agent(root)).root, join(facet, 'inner'))
|
||||||
|
|
||||||
|
// Clearing the agent's side has to land on the user's declaration. Landing on
|
||||||
|
// the session directory would make `reset` a widening.
|
||||||
|
await captured.tool.execute({ action: 'reset' }, exec)
|
||||||
|
assert.equal(policy.scopeFor(agent(root)).root, facet)
|
||||||
|
})
|
||||||
|
|
||||||
test('the facet tool refuses a call it cannot act on', async () => {
|
test('the facet tool refuses a call it cannot act on', async () => {
|
||||||
const { captured } = harness()
|
const { captured } = harness()
|
||||||
const exec = { agent: agent() }
|
const exec = { agent: agent() }
|
||||||
|
|||||||
@@ -93,3 +93,97 @@ test('prompt placements are carried, so the preset row needs no config', () => {
|
|||||||
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7)
|
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7)
|
||||||
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8)
|
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── per-session scope ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The declaration is the user's, written from the browser panel; the narrowing
|
||||||
|
// is the agent's, held in memory. Everything below is about which of the two
|
||||||
|
// wins, and the answer is always "the narrower one, and never a stale agent
|
||||||
|
// focus that the user has since narrowed past".
|
||||||
|
|
||||||
|
test('a declared focus narrows the root, and the session directory stays the base', () => {
|
||||||
|
const policy = createFacetPolicy({
|
||||||
|
mode: 'deny',
|
||||||
|
scopes: { s1: { focus: 'src/controllers/room' } },
|
||||||
|
})
|
||||||
|
const scope = policy.scopeFor(agentAt('/w', 's1'))
|
||||||
|
|
||||||
|
assert.equal(scope.root, '/w/src/controllers/room')
|
||||||
|
// `cwd` is still where relative paths resolve from — a declaration written
|
||||||
|
// against the session keeps its meaning whatever the session focuses on.
|
||||||
|
assert.equal(scope.cwd, '/w')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a declaration is read live, so the panel needs no remount', () => {
|
||||||
|
const scopes = {}
|
||||||
|
const policy = createFacetPolicy({ mode: 'deny', scopes })
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
|
||||||
|
|
||||||
|
// What a panel write does to the running reference.
|
||||||
|
scopes.s1 = { focus: 'src/module' }
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/module')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declared extras join the deployment seed and the session grants', () => {
|
||||||
|
const policy = createFacetPolicy({
|
||||||
|
mode: 'deny',
|
||||||
|
extraRoots: ['seed'],
|
||||||
|
scopes: { s1: { focus: 'src/room', extras: ['shared', '/abs/common'] } },
|
||||||
|
})
|
||||||
|
policy.grant('s1', ['granted'], '/w')
|
||||||
|
|
||||||
|
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, [
|
||||||
|
'/w/seed',
|
||||||
|
'/w/shared',
|
||||||
|
'/abs/common',
|
||||||
|
'/w/granted',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an agent narrowing is honoured while it stays inside the declared root', () => {
|
||||||
|
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src' } } })
|
||||||
|
policy.narrowTo('s1', '/w/src/room')
|
||||||
|
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
|
||||||
|
assert.equal(policy.narrowFor('s1'), '/w/src/room')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a narrowing the user has since narrowed past is dropped, not honoured', () => {
|
||||||
|
const scopes = { s1: { focus: 'src' } }
|
||||||
|
const policy = createFacetPolicy({ mode: 'deny', scopes })
|
||||||
|
policy.narrowTo('s1', '/w/src/room')
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
|
||||||
|
|
||||||
|
// The user narrows to a sibling: the agent's focus is now outside the scope
|
||||||
|
// they declared. Honouring it would put the session back where the user had
|
||||||
|
// just decided it should not be.
|
||||||
|
scopes.s1 = { focus: 'src/other' }
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/other')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reset and forget drop the agent side and leave the declaration alone', () => {
|
||||||
|
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src', extras: ['shared'] } } })
|
||||||
|
policy.narrowTo('s1', '/w/src/room')
|
||||||
|
policy.grant('s1', ['granted'], '/w')
|
||||||
|
|
||||||
|
policy.reset('s1')
|
||||||
|
// Back to what the user declared — narrower than the session, never wider.
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
|
||||||
|
assert.deepEqual(policy.grantsFor('s1'), [])
|
||||||
|
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/shared'])
|
||||||
|
|
||||||
|
policy.narrowTo('s1', '/w/src/room')
|
||||||
|
policy.forget('s1')
|
||||||
|
assert.equal(policy.narrowFor('s1'), undefined)
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('junk in a declaration is ignored rather than thrown on', () => {
|
||||||
|
const policy = createFacetPolicy({
|
||||||
|
mode: 'deny',
|
||||||
|
scopes: { s1: { focus: ' ', extras: ['ok', '', 42, null] }, s2: 'not an object' },
|
||||||
|
})
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
|
||||||
|
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok'])
|
||||||
|
assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w')
|
||||||
|
})
|
||||||
+6
-6
@@ -82,7 +82,7 @@ test('facetContextText carries the binding that does change', () => {
|
|||||||
deny,
|
deny,
|
||||||
'facet: /w/src/room\n' +
|
'facet: /w/src/room\n' +
|
||||||
'granted outside it: src/shared\n' +
|
'granted outside it: src/shared\n' +
|
||||||
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
|
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
|
||||||
)
|
)
|
||||||
|
|
||||||
// The binding is the only half that can follow a live edit, so the two modes
|
// The binding is the only half that can follow a live edit, so the two modes
|
||||||
@@ -97,14 +97,14 @@ test('facetContextText carries the binding that does change', () => {
|
|||||||
test('a session with no approval channel says so instead of promising a question', () => {
|
test('a session with no approval channel says so instead of promising a question', () => {
|
||||||
// `approval: never` refuses every ask before an answerer is consulted, so the
|
// `approval: never` refuses every ask before an answerer is consulted, so the
|
||||||
// mode stops mattering: `ask` and `deny` both end in a refusal, and
|
// mode stops mattering: `ask` and `deny` both end in a refusal, and
|
||||||
// `facet action=add` cannot be approved either. `canAsk: false` is the one
|
// `facet action=add` cannot be granted either. `canAsk: false` is the one
|
||||||
// input that collapses them, and it has to name the mechanism that still
|
// input that collapses them, and it has to name the mechanism that still
|
||||||
// works — `extraRoots` is configuration, not a per-conversation grant.
|
// works — the context panel, which the user owns.
|
||||||
for (const mode of ['ask', 'deny']) {
|
for (const mode of ['ask', 'deny']) {
|
||||||
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
|
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
|
||||||
assert.match(text, /outside the facet: refused — approval prompts are off in this session/)
|
assert.match(text, /outside the facet: refused — this session has no approval channel/)
|
||||||
assert.match(text, /`facet action=add` cannot be approved either/)
|
assert.match(text, /`facet action=add` cannot be granted/)
|
||||||
assert.match(text, /`extraRoots`/)
|
assert.match(text, /context panel/)
|
||||||
assert.doesNotMatch(text, /put to the user as a question/)
|
assert.doesNotMatch(text, /put to the user as a question/)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user