feat(acp): the bridge approval answerer + scripted permission answers
The ACP bridge registers the first real approval answerer: an ask for an agent it owns becomes session/request_permission attached to the already- streamed tool call (one-shot allow_once/reject_once only), outcomes map conservatively (unknown optionId never grants, client cancel → cancelled), and foreign or call-less requests delegate down the waterfall. The snapshot harness accepts scripted permissionAnswers (FIFO; an unscripted prompt answers cancelled, fail closed) so recorded scenarios can drive the wire keylessly.
This commit is contained in:
@@ -58,6 +58,13 @@ interface Behavior {
|
||||
strayBucketFile?: boolean
|
||||
/** Delete the sessions root entirely (harvest must yield no logs). */
|
||||
deleteSessionsRoot?: boolean
|
||||
/**
|
||||
* Vocabulary for `session/set_config_option`: allowed values per config id.
|
||||
* A set naming an unknown id or an out-of-vocabulary value rejects (the
|
||||
* real bridge's rule); a valid set answers with the complete refreshed
|
||||
* option state, `currentValue` updated. Absent: every set rejects.
|
||||
*/
|
||||
configOptions?: Record<string, string[]>
|
||||
}
|
||||
|
||||
const sessionsRoot = process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? ''
|
||||
@@ -81,6 +88,8 @@ let sessionCwd = ''
|
||||
let parkedPromptId: number | string | null = null
|
||||
/** Resolvers for permission-probe responses, keyed by outbound request id. */
|
||||
const pendingPermission = new Map<number, (outcome: unknown) => void>()
|
||||
/** Per-run `session/set_config_option` state: config id → current value (first vocabulary entry until set). */
|
||||
const currentConfig: Record<string, string> = {}
|
||||
|
||||
function send(frame: Record<string, unknown>): void {
|
||||
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`)
|
||||
@@ -195,6 +204,32 @@ function handleFrame(frame: Record<string, unknown>): void {
|
||||
case 'session/prompt':
|
||||
void handlePrompt(id as number | string)
|
||||
return
|
||||
case 'session/set_config_option': {
|
||||
const vocabulary = behavior.configOptions
|
||||
const configId = params.configId as string
|
||||
const value = params.value as string
|
||||
const values = vocabulary?.[configId]
|
||||
if (values === undefined) {
|
||||
respondError(id as number | string, `unknown config option ${configId}`)
|
||||
return
|
||||
}
|
||||
if (!values.includes(value)) {
|
||||
respondError(id as number | string, `unknown ${configId} value ${value}`)
|
||||
return
|
||||
}
|
||||
currentConfig[configId] = value
|
||||
// The real bridge's contract: every set answers with the COMPLETE
|
||||
// refreshed option state, not just the changed entry.
|
||||
respond(id as number | string, {
|
||||
configOptions: Object.entries(vocabulary as Record<string, string[]>).map(([cid, vs]) => ({
|
||||
id: cid,
|
||||
type: 'select',
|
||||
currentValue: currentConfig[cid] ?? vs[0],
|
||||
options: vs.map(v => ({ value: v, name: v })),
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'session/cancel':
|
||||
if (parkedPromptId !== null) {
|
||||
const parked = parkedPromptId
|
||||
|
||||
@@ -168,6 +168,8 @@ describe('runScenario', () => {
|
||||
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
|
||||
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
|
||||
[{ op: 'cancel' }, /cancel before newSession/],
|
||||
[{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'read-only' }, /setConfigOption before newSession/],
|
||||
[{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, /setConfigOptionExpectError before newSession/],
|
||||
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
|
||||
const { fixtureFile } = await scenario({})
|
||||
await expect(runScenario(
|
||||
@@ -176,6 +178,53 @@ describe('runScenario', () => {
|
||||
)).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('setConfigOption switches a value and receives the complete refreshed option state', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
configOptions: { 'sandbox-mode': ['read-only', 'workspace-write'], 'approval-policy': ['ask', 'never'] },
|
||||
})
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot,
|
||||
{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'workspace-write' },
|
||||
{ op: 'setConfigOption', configId: 'approval-policy', value: 'never' }],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
// Every set answers with the FULL state: the second response carries the
|
||||
// first switch's value too — the complete-refreshed-state contract.
|
||||
const frames = result.rawStdout.trim().split('\n').map(line => JSON.parse(line) as { result?: { configOptions?: { id: string; currentValue: string }[] } })
|
||||
const states = frames
|
||||
.map(f => f.result?.configOptions)
|
||||
.filter(options => options !== undefined)
|
||||
.map(options => Object.fromEntries((options as { id: string; currentValue: string }[]).map(o => [o.id, o.currentValue])))
|
||||
expect(states).toEqual([
|
||||
{ 'sandbox-mode': 'workspace-write', 'approval-policy': 'ask' },
|
||||
{ 'sandbox-mode': 'workspace-write', 'approval-policy': 'never' },
|
||||
])
|
||||
})
|
||||
|
||||
it('setConfigOptionExpectError swallows the rejection for unknown ids and out-of-vocabulary values', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } })
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot,
|
||||
{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' },
|
||||
{ op: 'setConfigOptionExpectError', configId: 'reasoning-effort', value: 'max' }],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
expect(result.rawStdout).toContain('unknown sandbox-mode value yolo')
|
||||
expect(result.rawStdout).toContain('unknown config option reasoning-effort')
|
||||
})
|
||||
|
||||
it('setConfigOptionExpectError throws when the set unexpectedly succeeds', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } })
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'read-only' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)).rejects.toThrow(/expected set_config_option to be rejected/)
|
||||
})
|
||||
|
||||
it('rejects an unknown input op', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({})
|
||||
const bogus = { op: 'reticulate' } as unknown as InputStep
|
||||
|
||||
Reference in New Issue
Block a user