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
+431
-52
No files matched your search
+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')
|
||||
})
|
||||
|
||||
test('the card stages exactly the fields the host marks volatile', async () => {
|
||||
test('the card stages exactly the deployment fields the host marks volatile', async () => {
|
||||
const { module: mod, staged } = await loadClient()
|
||||
drive(mod)
|
||||
|
||||
const declared = Object.entries(Config.dict)
|
||||
const volatileFields = Object.entries(Config.dict)
|
||||
.filter(([, schema]) => schema.meta.volatile === true)
|
||||
.map(([key]) => key)
|
||||
|
||||
// `scopes` is volatile too, but it is not a deployment field and must not be a
|
||||
// control on the card: it is per-session state, written by the context panel
|
||||
// and keyed by session id. Staging it here would render a dictionary blob a
|
||||
// user could not sensibly edit, and would put the panel's store on the one
|
||||
// surface that edits the deployment.
|
||||
assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it')
|
||||
const declared = volatileFields.filter((key) => key !== 'scopes')
|
||||
|
||||
// A field served but never staged is invisible in the UI; a field staged but
|
||||
// never served makes `whileServed` withhold the whole card.
|
||||
assert.deepEqual(
|
||||
|
||||
+61
-5
@@ -107,6 +107,7 @@ before(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
|
||||
facet = join(root, 'src', 'controllers', 'room')
|
||||
await mkdir(facet, { recursive: true })
|
||||
await mkdir(join(facet, 'inner'), { recursive: true })
|
||||
await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true })
|
||||
await mkdir(join(root, 'src', 'shared'), { recursive: true })
|
||||
await writeFile(join(facet, 'index.ts'), 'export {}\n')
|
||||
@@ -293,9 +294,10 @@ test('the binding refuses honestly when the session has no approval channel', ()
|
||||
const { captured } = harness({ mode: 'ask', approval: 'never' })
|
||||
const text = captured.context.text({ agent: agent() })
|
||||
|
||||
assert.match(text, /approval prompts are off in this session/)
|
||||
assert.match(text, /cannot be approved either/)
|
||||
assert.match(text, /`extraRoots`/)
|
||||
assert.match(text, /no approval channel/)
|
||||
assert.match(text, /cannot be granted/)
|
||||
// It names the mechanism that still works, which is the panel the user owns.
|
||||
assert.match(text, /context panel/)
|
||||
|
||||
// The promise `ask` makes cannot be kept here, so it must not be made.
|
||||
assert.doesNotMatch(text, /put to the user as a question/)
|
||||
@@ -308,14 +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,
|
||||
// so the binding must not offer one.
|
||||
const without = harness({ mode: 'ask' })
|
||||
assert.match(without.captured.context.text({ agent: agent() }), /approval prompts are off in this session/)
|
||||
assert.match(without.captured.context.text({ agent: agent() }), /no approval channel/)
|
||||
})
|
||||
|
||||
test('deny mode still names the way that works when there is no channel', () => {
|
||||
const { captured } = harness({ mode: 'deny', approval: 'never' })
|
||||
const text = captured.context.text({ agent: agent() })
|
||||
assert.match(text, /refused/)
|
||||
assert.match(text, /`extraRoots`/)
|
||||
assert.match(text, /context panel/)
|
||||
})
|
||||
|
||||
test('the facet tool reports the scope', async () => {
|
||||
@@ -347,6 +349,60 @@ test('the facet tool grants, narrows and clears', async () => {
|
||||
assert.deepEqual(policy.grantsFor('session-1'), [])
|
||||
})
|
||||
|
||||
test('the facet tool narrows inside the facet and refuses a folder outside it', async () => {
|
||||
const { captured, policy } = harness()
|
||||
const exec = { agent: agent() }
|
||||
|
||||
// Narrowing is the one scope change the agent may make unasked: it can only
|
||||
// ever reduce what is visible, so there is nothing for a user to approve.
|
||||
const narrowed = await captured.tool.execute({ action: 'focus', paths: ['inner'] }, exec)
|
||||
assert.equal(narrowed.root, join(facet, 'inner'))
|
||||
assert.equal(narrowed.facet, join(facet, 'inner'))
|
||||
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
|
||||
|
||||
// A folder outside the scope already in force is a widening by another name,
|
||||
// and widening is the user's decision, taken in their panel.
|
||||
await assert.rejects(
|
||||
() => captured.tool.execute({ action: 'focus', paths: ['../../shared'] }, exec),
|
||||
/outside the current facet/,
|
||||
)
|
||||
await assert.rejects(
|
||||
() => captured.tool.execute({ action: 'focus', paths: ['../other'] }, exec),
|
||||
/outside the current facet/,
|
||||
)
|
||||
// A refused narrowing must not have moved anything.
|
||||
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
|
||||
})
|
||||
|
||||
test('the facet tool needs exactly one folder to focus on', async () => {
|
||||
const { captured } = harness()
|
||||
const exec = { agent: agent() }
|
||||
await assert.rejects(() => captured.tool.execute({ action: 'focus' }, exec), /exactly one folder/)
|
||||
await assert.rejects(
|
||||
() => captured.tool.execute({ action: 'focus', paths: ['inner', '../other'] }, exec),
|
||||
/exactly one folder/,
|
||||
)
|
||||
})
|
||||
|
||||
test('reset lands on the scope the user declared, never back on the session directory', async () => {
|
||||
const { captured, policy } = harness({
|
||||
config: { mode: 'deny', scopes: { 'session-1': { focus: 'src/controllers/room' } } },
|
||||
})
|
||||
const exec = { agent: agent(root) }
|
||||
|
||||
// The user declared this conversation to be about the room controller, so the
|
||||
// session is already narrower than the directory it was started in.
|
||||
assert.equal(policy.scopeFor(agent(root)).root, facet)
|
||||
|
||||
await captured.tool.execute({ action: 'focus', paths: ['src/controllers/room/inner'] }, exec)
|
||||
assert.equal(policy.scopeFor(agent(root)).root, join(facet, 'inner'))
|
||||
|
||||
// Clearing the agent's side has to land on the user's declaration. Landing on
|
||||
// the session directory would make `reset` a widening.
|
||||
await captured.tool.execute({ action: 'reset' }, exec)
|
||||
assert.equal(policy.scopeFor(agent(root)).root, facet)
|
||||
})
|
||||
|
||||
test('the facet tool refuses a call it cannot act on', async () => {
|
||||
const { captured } = harness()
|
||||
const exec = { agent: agent() }
|
||||
|
||||
@@ -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 }).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,
|
||||
'facet: /w/src/room\n' +
|
||||
'granted outside it: src/shared\n' +
|
||||
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
|
||||
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
|
||||
)
|
||||
|
||||
// The binding is the only half that can follow a live edit, so the two modes
|
||||
@@ -97,14 +97,14 @@ test('facetContextText carries the binding that does change', () => {
|
||||
test('a session with no approval channel says so instead of promising a question', () => {
|
||||
// `approval: never` refuses every ask before an answerer is consulted, so the
|
||||
// mode stops mattering: `ask` and `deny` both end in a refusal, and
|
||||
// `facet action=add` cannot be approved either. `canAsk: false` is the one
|
||||
// `facet action=add` cannot be granted either. `canAsk: false` is the one
|
||||
// input that collapses them, and it has to name the mechanism that still
|
||||
// works — `extraRoots` is configuration, not a per-conversation grant.
|
||||
// works — the context panel, which the user owns.
|
||||
for (const mode of ['ask', 'deny']) {
|
||||
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
|
||||
assert.match(text, /outside the facet: refused — approval prompts are off in this session/)
|
||||
assert.match(text, /`facet action=add` cannot be approved either/)
|
||||
assert.match(text, /`extraRoots`/)
|
||||
assert.match(text, /outside the facet: refused — this session has no approval channel/)
|
||||
assert.match(text, /`facet action=add` cannot be granted/)
|
||||
assert.match(text, /context panel/)
|
||||
assert.doesNotMatch(text, /put to the user as a question/)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user