fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -1010,7 +1010,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
* quiescence"): for each session settle any pending prompt `cancelled`, then
|
||||
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
|
||||
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
|
||||
* final `turn/end` + `session/flush` are captured while `onAppend` is still
|
||||
* final `turn/end` + `session/flush` are captured while the store-owned append observer is still
|
||||
* attached), unregisters the agent, and removes its session from the store.
|
||||
* The per-session disposes run in parallel. Idempotent — clears the `sessions`
|
||||
* map first and memoizes, so a second call (close racing dispose) is a no-op.
|
||||
|
||||
@@ -159,8 +159,8 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
|
||||
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
|
||||
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
|
||||
// through the still-attached `session.onAppend` → `session/event`), and only
|
||||
// THEN detach onAppend + remove the session. If the order were inverted
|
||||
// through the still-attached store observer → `session/event`), and only
|
||||
// THEN detach that observer + remove the session. If the order were inverted
|
||||
// (detach first), the closing events would never reach persistence. Drive a
|
||||
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
|
||||
// persisted log from disk and assert the closing turn/end is on disk — the
|
||||
@@ -190,7 +190,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
|
||||
// still open when teardown runs: the composite agent effect stops the loop,
|
||||
// the loop unwinds and appends `turn/end {disposed}` + runs its final
|
||||
// `session/flush` — all while `onAppend` is still attached (the session
|
||||
// `session/flush` — all while the store-owned append observer is still attached (the session
|
||||
// detach is the LAST disposer in the same effect's LIFO chain) — and only
|
||||
// THEN is the session detached. If the order were inverted (or the session
|
||||
// were a racing SIBLING effect), the abort-produced `turn/end` would never
|
||||
@@ -255,7 +255,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// into ONE composite effect whose disposers run as a `.then()` chain. The
|
||||
// register disposer emits `agent/disposed`; if a listener throws and the
|
||||
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
|
||||
// disposer — stranding the session in the store with `onAppend` attached (a
|
||||
// disposer — stranding the session in the store with its append observer attached (a
|
||||
// leak AND a durability hole, since the new design relies on detach
|
||||
// running). The emit must be contained. Register a throwing listener, drive
|
||||
// a clean turn, dispose, and assert the session was STILL removed.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
|
||||
|
||||
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact `agent` and `AbortSignal` identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.
|
||||
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its decision phase always resolves to an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair.
|
||||
|
||||
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
|
||||
|
||||
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
|
||||
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
|
||||
|
||||
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).
|
||||
|
||||
|
||||
@@ -223,12 +223,16 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
|
||||
* THE write path for a session's approval-policy override: appends exactly
|
||||
* one `approval/policy` event — the switch IS its event; nothing mutates
|
||||
* policy state out of band. Takes effect on the session's next ask and next
|
||||
* prompt assembly (the consumers fold on every read).
|
||||
* prompt assembly (the consumers fold on every read). Rejects a value outside
|
||||
* {@link APPROVAL_POLICIES} before appending anything.
|
||||
* @param session - the session the override belongs to.
|
||||
* @param policy - the policy every subsequent ask for this session resolves
|
||||
* under (until the next switch).
|
||||
*/
|
||||
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
|
||||
if (!APPROVAL_POLICIES.includes(policy)) {
|
||||
throw new TypeError('approval policy must be one of "ask" or "never"')
|
||||
}
|
||||
session.append('approval/policy', { policy })
|
||||
}
|
||||
|
||||
@@ -238,8 +242,10 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
|
||||
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
|
||||
* attaches the prompt to the already-streamed tool call via `callId` instead
|
||||
* of re-rendering the call. `request()` synchronously copies and shallow-freezes
|
||||
* this record before crossing an asynchronous boundary. Scalar fields are
|
||||
* detached; the `agent` and `signal` identity capabilities are preserved.
|
||||
* this record before crossing an asynchronous boundary. It reads each field
|
||||
* and the agent's session binding once, validates the public fixed-field
|
||||
* contract before audit, and detaches the scalar values; the `agent` and live
|
||||
* `signal` identity capabilities are preserved rather than cloned or frozen.
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
/**
|
||||
@@ -264,6 +270,13 @@ export interface ApprovalRequest {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Live signal capability accepted at the synchronous request boundary. */
|
||||
interface AcceptedSignal {
|
||||
signal: AbortSignal
|
||||
addEventListener: AbortSignal['addEventListener']
|
||||
removeEventListener: AbortSignal['removeEventListener']
|
||||
}
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -297,7 +310,7 @@ export class ApprovalService extends Service {
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'approval')
|
||||
|
||||
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
|
||||
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
|
||||
|
||||
// Visibility layer 1, scoped on the prompt registry so headless
|
||||
// compositions mount the seam without it: state the one deterministic
|
||||
@@ -345,7 +358,7 @@ export class ApprovalService extends Service {
|
||||
}
|
||||
// Same fold effectivePolicy performs — override is scanned here anyway
|
||||
// for POSITIONAL attribution; the default lives once, in the method.
|
||||
const current = this.effectivePolicy(agent)
|
||||
const current = this.effectivePolicy(session)
|
||||
const header = session.requestHeader()
|
||||
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
|
||||
narrated.set(session, current)
|
||||
@@ -361,17 +374,21 @@ export class ApprovalService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the composed answerers to decide one request. Requires an open turn
|
||||
* on the requesting agent's session — the audit pair below is turn-enclosed
|
||||
* by contract (the turn is the log's commit/replay boundary; an idle append
|
||||
* would be dropped as crash tail on reload) — and throws before appending
|
||||
* anything when called idle; asking outside a turn is a deferred design.
|
||||
* Within that precondition it always resolves to an outcome, never rejects:
|
||||
* an aborted signal yields `'cancelled'`, a missing or throwing answerer
|
||||
* yields `'unavailable'` (fail closed), and a rogue non-vocabulary return
|
||||
* value is normalized to `'unavailable'`. The caller-owned request is
|
||||
* synchronously snapshotted, so later mutation cannot split routing,
|
||||
* dispatch payload, cancellation, or the audit pair across agents/sessions.
|
||||
* Ask the composed answerers to decide one request. Synchronously reads each
|
||||
* request field and the agent's session binding once, validates the fixed
|
||||
* agent/session, string, and live-signal contracts, and rejects before any
|
||||
* audit append when malformed. The signal remains the caller's exact live
|
||||
* identity capability; it is neither cloned nor frozen. Requires an open
|
||||
* turn on the accepted session — the audit pair below is turn-enclosed by
|
||||
* contract (the turn is the log's commit/replay boundary; an idle append
|
||||
* would be dropped as crash tail on reload) — and likewise throws before
|
||||
* appending anything when called idle; asking outside a turn is a deferred
|
||||
* design. Once accepted it always resolves to an outcome, never rejects: an
|
||||
* aborted signal yields `'cancelled'`, a missing or throwing answerer yields
|
||||
* `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
|
||||
* normalized to `'unavailable'`. The caller-owned request is synchronously
|
||||
* snapshotted, so later mutation cannot split routing, dispatch payload,
|
||||
* cancellation, policy lookup, or the audit pair across agents/sessions.
|
||||
* Appends the
|
||||
* `approval/asked`/`approval/decided` audit pair (log-only) around the
|
||||
* decision regardless of outcome. A synchronous session observer failure
|
||||
@@ -385,20 +402,73 @@ export class ApprovalService extends Service {
|
||||
// Accept one immutable request shape before the first async boundary. The
|
||||
// caller retains its record and may mutate it as soon as this async method
|
||||
// returns; identity capabilities stay live, but the record is never reread.
|
||||
const agent = req.agent
|
||||
const toolName = req.toolName
|
||||
const callId = req.callId
|
||||
const reason = req.reason
|
||||
const signal = req.signal
|
||||
const input: unknown = req
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
throw new TypeError('approval.request() requires a request object')
|
||||
}
|
||||
const source = input as Record<string, unknown>
|
||||
const agentInput = source['agent']
|
||||
const toolName = source['toolName']
|
||||
const callId = source['callId']
|
||||
const reason = source['reason']
|
||||
const signalInput = source['signal']
|
||||
if (typeof agentInput !== 'object' || agentInput === null) {
|
||||
throw new TypeError('approval request agent must be an object')
|
||||
}
|
||||
if (typeof toolName !== 'string') {
|
||||
throw new TypeError('approval request toolName must be a string')
|
||||
}
|
||||
if (callId !== undefined && typeof callId !== 'string') {
|
||||
throw new TypeError('approval request callId must be a string when provided')
|
||||
}
|
||||
if (reason !== undefined && typeof reason !== 'string') {
|
||||
throw new TypeError('approval request reason must be a string when provided')
|
||||
}
|
||||
let acceptedSignal: AcceptedSignal | undefined
|
||||
if (signalInput !== undefined) {
|
||||
if (typeof signalInput !== 'object' || signalInput === null) {
|
||||
throw new TypeError('approval request signal must be an AbortSignal when provided')
|
||||
}
|
||||
const signalRecord = signalInput as unknown as Record<string, unknown>
|
||||
const aborted = signalRecord['aborted']
|
||||
const addEventListener = signalRecord['addEventListener']
|
||||
const removeEventListener = signalRecord['removeEventListener']
|
||||
if (typeof aborted !== 'boolean'
|
||||
|| typeof addEventListener !== 'function'
|
||||
|| typeof removeEventListener !== 'function') {
|
||||
throw new TypeError('approval request signal must be an AbortSignal when provided')
|
||||
}
|
||||
acceptedSignal = {
|
||||
signal: signalInput as AbortSignal,
|
||||
addEventListener: addEventListener as AbortSignal['addEventListener'],
|
||||
removeEventListener: removeEventListener as AbortSignal['removeEventListener'],
|
||||
}
|
||||
}
|
||||
const sessionInput = (agentInput as unknown as Record<string, unknown>)['session']
|
||||
if (typeof sessionInput !== 'object' || sessionInput === null) {
|
||||
throw new TypeError('approval request agent session must be an object')
|
||||
}
|
||||
const sessionRecord = sessionInput as unknown as Record<string, unknown>
|
||||
const events = sessionRecord['events']
|
||||
const append = sessionRecord['append']
|
||||
if (!Array.isArray(events)) {
|
||||
throw new TypeError('approval request session events must be an array')
|
||||
}
|
||||
if (typeof append !== 'function') {
|
||||
throw new TypeError('approval request session append must be a function')
|
||||
}
|
||||
const agent = agentInput as Agent
|
||||
const session = sessionInput as Session
|
||||
const acceptedCallId = callId as CallId | undefined
|
||||
const signal = signalInput as AbortSignal | undefined
|
||||
const accepted: Readonly<ApprovalRequest> = Object.freeze({
|
||||
agent,
|
||||
toolName,
|
||||
...callId !== undefined ? { callId } : {},
|
||||
...acceptedCallId !== undefined ? { callId: acceptedCallId } : {},
|
||||
...reason !== undefined ? { reason } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
const session = accepted.agent.session
|
||||
if (!hasOpenTurn(session.events)) {
|
||||
if (!hasOpenTurn(events)) {
|
||||
throw new Error(
|
||||
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
|
||||
+ 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
|
||||
@@ -407,16 +477,16 @@ export class ApprovalService extends Service {
|
||||
}
|
||||
const id = ApprovalRequestId(randomUUID())
|
||||
this.appendAudit(session, 'approval/asked', id, () => {
|
||||
session.append('approval/asked', {
|
||||
Reflect.apply(append, session, ['approval/asked', {
|
||||
id,
|
||||
toolName: accepted.toolName,
|
||||
...accepted.callId !== undefined ? { callId: accepted.callId } : {},
|
||||
...accepted.reason !== undefined ? { reason: accepted.reason } : {},
|
||||
})
|
||||
}])
|
||||
})
|
||||
const outcome = await this.decide(accepted)
|
||||
const outcome = await this.decide(accepted, session, acceptedSignal)
|
||||
this.appendAudit(session, 'approval/decided', id, () => {
|
||||
session.append('approval/decided', { id, outcome })
|
||||
Reflect.apply(append, session, ['approval/decided', { id, outcome }])
|
||||
})
|
||||
return outcome
|
||||
}
|
||||
@@ -451,22 +521,30 @@ export class ApprovalService extends Service {
|
||||
* The session's effective policy: its own `approval/policy` fold, else the
|
||||
* configured default (the schema already defaulted an omitted policy to
|
||||
* `'ask'`; the `??` only narrows the optional-input TYPE).
|
||||
* @param agent - the agent whose session's policy applies.
|
||||
* @returns the policy every ask for this agent resolves under right now.
|
||||
* @param session - the exact accepted session whose policy applies.
|
||||
* @returns the policy every ask for this session resolves under right now.
|
||||
*/
|
||||
private effectivePolicy(agent: Agent): ApprovalPolicy {
|
||||
return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask'
|
||||
private effectivePolicy(session: Session): ApprovalPolicy {
|
||||
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
|
||||
}
|
||||
|
||||
/** Dispatch the waterfall, contained and raced against the accepted signal. */
|
||||
private async decide(req: Readonly<ApprovalRequest>): Promise<ApprovalOutcome> {
|
||||
if (req.signal?.aborted) return 'cancelled'
|
||||
/**
|
||||
* Dispatch the waterfall, contained and raced against the accepted signal.
|
||||
* @param req - the detached public request snapshot.
|
||||
* @param session - the captured session used for policy lookup.
|
||||
* @param acceptedSignal - the validated live signal capability, if supplied.
|
||||
* @returns the normalized closed outcome.
|
||||
*/
|
||||
private async decide(
|
||||
req: Readonly<ApprovalRequest>, session: Session, acceptedSignal: AcceptedSignal | undefined,
|
||||
): Promise<ApprovalOutcome> {
|
||||
if (acceptedSignal?.signal.aborted) return 'cancelled'
|
||||
// The 'never' policy is decided HERE, before any dispatch: a listener
|
||||
// registered with `prepend: true` after this service mounts would sit
|
||||
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
|
||||
// documented promise that 'never' rejects deterministically regardless
|
||||
// of registration order — only the service's own request path can.
|
||||
if (this.effectivePolicy(req.agent) === 'never') return 'rejected'
|
||||
if (this.effectivePolicy(session) === 'never') return 'rejected'
|
||||
// Enter the promise chain BEFORE dispatching: a listener that throws
|
||||
// SYNCHRONOUSLY (before its first await) must land in the same rejection
|
||||
// path as an async one — `Promise.resolve(call())` would let it escape
|
||||
@@ -484,13 +562,13 @@ export class ApprovalService extends Service {
|
||||
// tool call open — the seam contains its callbacks.
|
||||
() => 'unavailable',
|
||||
)
|
||||
const signal = req.signal
|
||||
if (signal === undefined) return answer
|
||||
if (acceptedSignal === undefined) return answer
|
||||
const { signal, addEventListener, removeEventListener } = acceptedSignal
|
||||
return await new Promise<ApprovalOutcome>((resolve) => {
|
||||
const onAbort = () => { resolve('cancelled') }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
addEventListener.call(signal, 'abort', onAbort, { once: true })
|
||||
void answer.then((outcome) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
removeEventListener.call(signal, 'abort', onAbort)
|
||||
// After an abort won the race this resolve is a settled-promise no-op:
|
||||
// the late answer is discarded by construction.
|
||||
resolve(outcome)
|
||||
|
||||
@@ -39,6 +39,158 @@ function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): Appr
|
||||
}
|
||||
|
||||
describe('ApprovalService.request', () => {
|
||||
it('rejects malformed fixed fields and identities before appending or dispatching', async () => {
|
||||
const ctx = await mounted()
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', () => {
|
||||
consulted()
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
const { agent, appended } = fakeAgent()
|
||||
const badSessionAppends: Array<ReturnType<typeof vi.fn>> = []
|
||||
const badSession = (events: unknown, append: unknown): Agent => ({
|
||||
session: { events, append },
|
||||
}) as unknown as Agent
|
||||
const appendSpy = (): ReturnType<typeof vi.fn> => {
|
||||
const append = vi.fn()
|
||||
badSessionAppends.push(append)
|
||||
return append
|
||||
}
|
||||
const validSignalShape = {
|
||||
aborted: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}
|
||||
const cases: Array<{ request: unknown; message: string }> = [
|
||||
{ request: null, message: 'requires a request object' },
|
||||
{ request: 1, message: 'requires a request object' },
|
||||
{ request: { agent: null, toolName: 'echo' }, message: 'agent must be an object' },
|
||||
{ request: { agent: 1, toolName: 'echo' }, message: 'agent must be an object' },
|
||||
{ request: { agent, toolName: 1 }, message: 'toolName must be a string' },
|
||||
{ request: { agent, toolName: 'echo', callId: 1 }, message: 'callId must be a string' },
|
||||
{ request: { agent, toolName: 'echo', reason: 1 }, message: 'reason must be a string' },
|
||||
{ request: { agent, toolName: 'echo', signal: null }, message: 'signal must be an AbortSignal' },
|
||||
{ request: { agent, toolName: 'echo', signal: 1 }, message: 'signal must be an AbortSignal' },
|
||||
{
|
||||
request: { agent, toolName: 'echo', signal: { ...validSignalShape, aborted: 'no' } },
|
||||
message: 'signal must be an AbortSignal',
|
||||
},
|
||||
{
|
||||
request: { agent, toolName: 'echo', signal: { ...validSignalShape, addEventListener: 1 } },
|
||||
message: 'signal must be an AbortSignal',
|
||||
},
|
||||
{
|
||||
request: { agent, toolName: 'echo', signal: { ...validSignalShape, removeEventListener: 1 } },
|
||||
message: 'signal must be an AbortSignal',
|
||||
},
|
||||
{
|
||||
request: { agent: { session: null }, toolName: 'echo' },
|
||||
message: 'agent session must be an object',
|
||||
},
|
||||
{
|
||||
request: { agent: { session: 1 }, toolName: 'echo' },
|
||||
message: 'agent session must be an object',
|
||||
},
|
||||
{
|
||||
request: { agent: badSession(null, appendSpy()), toolName: 'echo' },
|
||||
message: 'session events must be an array',
|
||||
},
|
||||
{
|
||||
request: { agent: badSession([{ type: 'turn/start' }], 1), toolName: 'echo' },
|
||||
message: 'session append must be a function',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { request, message } of cases) {
|
||||
await expect(ctx.approval.request(request as ApprovalRequest)).rejects.toThrow(message)
|
||||
}
|
||||
|
||||
expect(appended).toEqual([])
|
||||
for (const append of badSessionAppends) expect(append).not.toHaveBeenCalled()
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads request fields, the agent session, and the session append method once', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent: acceptedSessionOwner, appended: acceptedAudit } = fakeAgent()
|
||||
const { agent: replacementAgent, appended: replacementAudit } = fakeAgent()
|
||||
const acceptedSession = acceptedSessionOwner.session
|
||||
const acceptedAppend = acceptedSession.append.bind(acceptedSession)
|
||||
const signal = new AbortController().signal
|
||||
const reads = {
|
||||
agent: 0,
|
||||
toolName: 0,
|
||||
callId: 0,
|
||||
reason: 0,
|
||||
signal: 0,
|
||||
session: 0,
|
||||
append: 0,
|
||||
}
|
||||
const session = {
|
||||
events: acceptedSession.events,
|
||||
get append(): Session['append'] {
|
||||
reads.append += 1
|
||||
return reads.append === 1 ? acceptedAppend : undefined as unknown as Session['append']
|
||||
},
|
||||
} as Session
|
||||
const agent = Object.defineProperty({}, 'session', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads.session += 1
|
||||
return reads.session === 1 ? session : replacementAgent.session
|
||||
},
|
||||
}) as Agent
|
||||
const request = Object.defineProperties({}, {
|
||||
agent: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.agent === 1 ? agent : null),
|
||||
},
|
||||
toolName: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.toolName === 1 ? 'stable-tool' : 1),
|
||||
},
|
||||
callId: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.callId === 1 ? CallId('stable-call') : {}),
|
||||
},
|
||||
reason: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.reason === 1 ? 'stable reason' : {}),
|
||||
},
|
||||
signal: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.signal === 1 ? signal : {}),
|
||||
},
|
||||
}) as ApprovalRequest
|
||||
let received: ApprovalRequest | undefined
|
||||
ctx.on('approval/request', (accepted) => {
|
||||
received = accepted
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
|
||||
await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
|
||||
|
||||
expect(reads).toEqual({
|
||||
agent: 1,
|
||||
toolName: 1,
|
||||
callId: 1,
|
||||
reason: 1,
|
||||
signal: 1,
|
||||
session: 1,
|
||||
append: 1,
|
||||
})
|
||||
expect(received).toMatchObject({
|
||||
agent,
|
||||
toolName: 'stable-tool',
|
||||
callId: 'stable-call',
|
||||
reason: 'stable reason',
|
||||
signal,
|
||||
})
|
||||
expect(Object.isFrozen(received)).toBe(true)
|
||||
expect(acceptedAudit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(replacementAudit).toEqual([])
|
||||
})
|
||||
|
||||
it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent([])
|
||||
@@ -420,6 +572,15 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
|
||||
})
|
||||
|
||||
it('rejects a policy outside the closed vocabulary before appending', () => {
|
||||
const append = vi.fn()
|
||||
const session = { append } as unknown as Session
|
||||
|
||||
expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters<typeof setApprovalPolicy>[1]) })
|
||||
.toThrow('approval policy must be one of "ask" or "never"')
|
||||
expect(append).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
|
||||
// Direct construction bypasses the plugin schema (the SystemPrompt-test
|
||||
// precedent for covering a defaulted Config field's type-narrowing ??).
|
||||
|
||||
Reference in New Issue
Block a user