diff --git a/.changeset/harness-anonymous-input.md b/.changeset/harness-anonymous-input.md new file mode 100644 index 0000000000..d4b5e37b79 --- /dev/null +++ b/.changeset/harness-anonymous-input.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-harness': patch +--- + +An input with no user no longer runs as the user who opened the session that owns the thread. A host with access refuses that input. diff --git a/docs/harness/shared-threads.md b/docs/harness/shared-threads.md index 1716445ac2..4a016b074b 100644 --- a/docs/harness/shared-threads.md +++ b/docs/harness/shared-threads.md @@ -146,7 +146,7 @@ await bobSession.send('Summarize the open issues.') Each message, command, and agent runs as the person who started it: -- The log keeps the sender of each input, also of a cancel, an answer, and a change of settings. After a restart, a message runs again as its sender. +- The log keeps the sender of each input, also of a cancel, an answer, and a change of settings. After a restart, a message runs again as its sender. An input with no sender runs with no user, so it does not use the credentials of the session that owns the thread. A host with access refuses that input. - Tools and middleware read the run as `ctx.context.run`. It has the user, the credentials, and the provider keys of the sender. - The model call uses the provider key of the sender. So the sender pays for the call. - An answer does not change the sender. When the owner approves a tool in the turn of Bob, the rest of that turn still runs as Bob. diff --git a/packages/ai-harness/src/session/agents.ts b/packages/ai-harness/src/session/agents.ts index a7ef1aaf46..fe4859fbce 100644 --- a/packages/ai-harness/src/session/agents.ts +++ b/packages/ai-harness/src/session/agents.ts @@ -245,7 +245,7 @@ export abstract class SessionAgents< if (runInputId === undefined) { return { inputId, status: 'rejected', reason: 'not_running' } } - const user = options.user ?? this.user + const user = this.actor(options.user) return this.admitting(async () => { const { admission, input } = await this.accept( inputId, @@ -688,7 +688,10 @@ export abstract class SessionAgents< // The agent runs for the user who starts it, also after the turn that // starts it ends. A child of agent code runs for its parent run's user. // Without either, it runs for the user that opened the session. - const user = options.user ?? (parent ? parent.current.user : this.user) + const user = this.actor( + options.user, + parent ? parent.current.user : this.user, + ) const sender = user ? { user } : {} const fromParent = parent ? { @@ -2448,7 +2451,7 @@ export abstract class SessionAgents< const connector = opened && signInOf(interruptOf(opened))?.connector if (!connector) continue // Another user's credential never answers this sign-in. - const user = wait.user ?? this.user + const user = wait.user if (!(await this.credentialsOf(user).get(connector))) continue await this.resolve({ resume: [ diff --git a/packages/ai-harness/src/session/base.ts b/packages/ai-harness/src/session/base.ts index 4d820bc9df..47d7dba0d0 100644 --- a/packages/ai-harness/src/session/base.ts +++ b/packages/ai-harness/src/session/base.ts @@ -396,6 +396,11 @@ export abstract class SessionBase { protected activeTurn: OperationImpl | undefined /** The open skipped recovery: another host owned the thread then. */ protected recoveryDeferred = false + /** + * The call stack is applying an offered input whose record has no user. + * Entry points then keep that absence. They do not use `this.user`. + */ + protected anonymousOffer = false /** * Agent runs that another host runs for an input that this host offered. * They wait for the log, so they do not keep the thread busy here. @@ -902,12 +907,27 @@ export abstract class SessionBase { } /** - * Who sent the input of the running turn, else the user that opened + * Who sent the input of the running turn. While a turn runs, that is + * `turnUser`, also when the input has no user. Else the user that opened * the session. Turns run one at a time, so one value is enough. It never * picks credentials or keys: those come from the run context of each run. */ protected sender(): HarnessUser | undefined { - return this.turnUser ?? this.user + if (this.activeTurn) return this.turnUser + return this.user + } + + /** + * The user an input runs as. While `anonymousOffer` is set, the input + * has no user. Else `user` when the caller passed one, else `fallback` + * (the user that opened the session). + */ + protected actor( + user: HarnessUser | undefined, + fallback: HarnessUser | undefined = this.user, + ): HarnessUser | undefined { + if (this.anonymousOffer) return undefined + return user ?? fallback } /** diff --git a/packages/ai-harness/src/session/harness-session.ts b/packages/ai-harness/src/session/harness-session.ts index 91a624efda..8310ffe3d9 100644 --- a/packages/ai-harness/src/session/harness-session.ts +++ b/packages/ai-harness/src/session/harness-session.ts @@ -930,7 +930,26 @@ export class SessionImpl const runs = this.runStore const maxAttempts = this.harness.durability?.maxAttempts ?? 10 const logged = [...writer.state.inputs.values()] + // An input with no user does not take this session's user. A host + // with access refuses it. The same check runs in the loop below. + const refuseAnonymous = async (input: InputState) => { + if (!this.hasAccess || input.user) return false + if (input.status !== 'pending' && input.status !== 'applied') return true + if (isTaken(input)) return true + if (input.status === 'pending') { + this.reject(input.inputId, 'no_user') + } else { + await this.settle({ + inputId: input.inputId, + ...(input.operationId ? { operationId: input.operationId } : {}), + outcome: 'failed', + error: { message: 'This input has no user.' }, + }) + } + return true + } for (const input of logged) { + if (await refuseAnonymous(input)) continue const isControl = input.status === 'pending' && CONTROL_OPS.has(input.input.op) if (isControl && !isTaken(input)) await this.recoverControl(writer, input) @@ -938,6 +957,7 @@ export class SessionImpl // Read again: the pass above changed the inputs it applied to. const inputs = [...writer.state.inputs.values()] for (const input of inputs) { + if (await refuseAnonymous(input)) continue // A reset that did not settle applies now. Its marker goes in once. if (input.input.op === 'reset') { const isOpen = diff --git a/packages/ai-harness/src/session/inputs.ts b/packages/ai-harness/src/session/inputs.ts index 666f3953a4..783d09bb7d 100644 --- a/packages/ai-harness/src/session/inputs.ts +++ b/packages/ai-harness/src/session/inputs.ts @@ -307,7 +307,7 @@ export abstract class SessionInputs< const { admission, input } = await this.accept( inputId, { op: 'append', records: [...records] }, - user ?? this.user, + this.actor(user), ) if (admission !== 'new') return this.duplicateReceipt(inputId, admission) // `acceptInput` can change the records. @@ -634,7 +634,7 @@ export abstract class SessionInputs< protected async accept( inputId: string, input: TInput, - user = this.user, + user: HarnessUser | undefined, options: AcceptOptions = {}, ): Promise<{ admission: Admission; input: TInput }> { // Before the first `await`: only this call takes them. @@ -948,156 +948,173 @@ export abstract class SessionInputs< () => {}, ) } - switch (input.op) { - case 'prompt': - ignore( - this.sendForRun(input.message, { - ...sent, - ...turn, - ...(input.busy ? { busy: input.busy } : {}), - ...(input.as ? { as: input.as } : {}), - ...run, - ...traced, - }), - ) - return - case 'continue': - ignore(session.continueWithoutMessage({ ...sent, ...turn })) - return - case 'steer': - ignore( - session.send(input.message, { - ...sent, - ...turn, - ...traced, - mode: 'steer', - }), - ) - return - case 'followUp': - ignore( - this.startTurn(input, { - ...sent, - ...turn, - ...traced, - mode: 'followUp', - ...(input.wake ? { wake: true } : {}), - }), - ) - return - case 'resolve': - ignore(this.resolveForRun({ resume: input.resume, ...sent, ...run })) - return - case 'reset': - ignore(session.reset({ note: input.note, ...sent })) - return - case 'configure': - ignore( - session.updateThreadSettings({ settings: input.settings, ...sent }), - ) - return - case 'agentMessage': - ignore( - session.send(input.message, { - ...sent, - to: input.operationId, - mode: input.mode, - }), - ) - return - case 'agent': - ignore( - this.runAgent(input.agent, input.input, { - ...asUser, - ...(input.detached ? { startTurnWhenDone: true } : {}), - ...(input.runAgainAfterCrash ? { runAgainAfterCrash: true } : {}), - ...(input.addResultNote === false ? { addResultNote: false } : {}), - offered: { inputId, ...run }, - }), - ) - return - case 'cancel': - ignore( - session.cancel({ - operationId: input.operationId, - inputId, - ...asUser, - }), - ) - return - case 'cancelInput': - ignore( - this.applyCancelInput({ - inputId: input.inputId, - controlId: inputId, - ...asUser, - }), - ) - return - case 'setDelivery': - ignore( - this.applySetDelivery({ - inputId: input.inputId, - delivery: input.delivery, - controlId: inputId, - ...asUser, - }), - ) - return - case 'command': - ignore( - session.runCommand({ - name: input.name, - input: input.input, - inputId, - ...asUser, - }), - ) - return - case 'answer': - ignore( - this.applyAnswer({ - questionId: input.questionId, - value: input.value, - ...(input.ref !== undefined ? { ref: input.ref } : {}), - controlId: inputId, - ...asUser, - }), - ) - return - case 'config': - ignore( - this.applyConfig({ - key: input.key, - value: input.value, - controlId: inputId, - ...asUser, - }), - ) - return - case 'tool': - ignore( - session.runTool({ - name: input.name, - // Wire args are unknown here. The tool's input schema checks them. - args: input.args as never, - inputId, - ...asUser, - ...(input.meta ? { meta: input.meta } : {}), - }), - ) - return - case 'append': - // Only an append with an input id is an input that a host offers. - ignore( - session.appendRecords({ records: input.records, inputId, ...asUser }), - ) - return - case 'revert': - case 'unrevert': - case 'background': - // These run only where they are called. No host offers them. - return + // A host with access does not run an input that names no user. + if (!user && this.hasAccess) { + ignore(this.refuseOffer(inputId, 'no_user')) + return + } + // Set before the switch: each call reads `actor` before its first await. + this.anonymousOffer = !user + try { + switch (input.op) { + case 'prompt': + ignore( + this.sendForRun(input.message, { + ...sent, + ...turn, + ...(input.busy ? { busy: input.busy } : {}), + ...(input.as ? { as: input.as } : {}), + ...run, + ...traced, + }), + ) + return + case 'continue': + ignore(session.continueWithoutMessage({ ...sent, ...turn })) + return + case 'steer': + ignore( + session.send(input.message, { + ...sent, + ...turn, + ...traced, + mode: 'steer', + }), + ) + return + case 'followUp': + ignore( + this.startTurn(input, { + ...sent, + ...turn, + ...traced, + mode: 'followUp', + ...(input.wake ? { wake: true } : {}), + }), + ) + return + case 'resolve': + ignore(this.resolveForRun({ resume: input.resume, ...sent, ...run })) + return + case 'reset': + ignore(session.reset({ note: input.note, ...sent })) + return + case 'configure': + ignore( + session.updateThreadSettings({ settings: input.settings, ...sent }), + ) + return + case 'agentMessage': + ignore( + session.send(input.message, { + ...sent, + to: input.operationId, + mode: input.mode, + }), + ) + return + case 'agent': + ignore( + this.runAgent(input.agent, input.input, { + ...asUser, + ...(input.detached ? { startTurnWhenDone: true } : {}), + ...(input.runAgainAfterCrash ? { runAgainAfterCrash: true } : {}), + ...(input.addResultNote === false + ? { addResultNote: false } + : {}), + offered: { inputId, ...run }, + }), + ) + return + case 'cancel': + ignore( + session.cancel({ + operationId: input.operationId, + inputId, + ...asUser, + }), + ) + return + case 'cancelInput': + ignore( + this.applyCancelInput({ + inputId: input.inputId, + controlId: inputId, + ...asUser, + }), + ) + return + case 'setDelivery': + ignore( + this.applySetDelivery({ + inputId: input.inputId, + delivery: input.delivery, + controlId: inputId, + ...asUser, + }), + ) + return + case 'command': + ignore( + session.runCommand({ + name: input.name, + input: input.input, + inputId, + ...asUser, + }), + ) + return + case 'answer': + ignore( + this.applyAnswer({ + questionId: input.questionId, + value: input.value, + ...(input.ref !== undefined ? { ref: input.ref } : {}), + controlId: inputId, + ...asUser, + }), + ) + return + case 'config': + ignore( + this.applyConfig({ + key: input.key, + value: input.value, + controlId: inputId, + ...asUser, + }), + ) + return + case 'tool': + ignore( + session.runTool({ + name: input.name, + // Wire args are unknown here. The tool's input schema checks them. + args: input.args as never, + inputId, + ...asUser, + ...(input.meta ? { meta: input.meta } : {}), + }), + ) + return + case 'append': + // Only an append with an input id is an input that a host offers. + ignore( + session.appendRecords({ + records: input.records, + inputId, + ...asUser, + }), + ) + return + case 'revert': + case 'unrevert': + case 'background': + // These run only where they are called. No host offers them. + return + } + } finally { + this.anonymousOffer = false } } @@ -1177,8 +1194,7 @@ export abstract class SessionInputs< * change: the event has `offered`, so only the host that offered the * input ends its wait with the refusal. */ - private async refuseOffer(inputId: string) { - const reason = 'conflict' + private async refuseOffer(inputId: string, reason = 'conflict') { await this.writer.append( [{ type: 'harness.offer.rejected', inputId, reason }], { consumes: [inputId] }, diff --git a/packages/ai-harness/src/session/requests.ts b/packages/ai-harness/src/session/requests.ts index c492469700..03f209c460 100644 --- a/packages/ai-harness/src/session/requests.ts +++ b/packages/ai-harness/src/session/requests.ts @@ -90,7 +90,9 @@ export abstract class SessionRequests< * `user` is who sent the message. Default: the user that opened * the session. The log keeps it, and the turn runs with its credentials * and provider keys. A steer that joins the running turn runs with the - * credentials of that turn's sender. + * credentials of that turn's sender. An input the log stored with no + * user runs with no user. It does not take the user of the session + * that owns the thread. A host with access refuses that input. * * `context` is JSON data from the client, stored with the input. Tools * get it in their context (see `HarnessConfig.context`). Do not trust it. @@ -196,7 +198,7 @@ export abstract class SessionRequests< const systemPreamble = options?.systemPreamble const ephemeral = options?.ephemeral const outputSchema = options?.outputSchema - const user = options?.user ?? this.user + const user = this.actor(options?.user) const wake = options?.wake === true const stored: typeof input = input.op === 'followUp' && wake ? { ...input, wake: true } : input @@ -381,7 +383,7 @@ export abstract class SessionRequests< user?: HarnessUser }): Operation { const inputId = options.inputId ?? createInputId() - const user = options.user ?? this.user + const user = this.actor(options.user) const input = { op: 'tool' as const, name: options.name, @@ -469,7 +471,7 @@ export abstract class SessionRequests< ephemeral?: ReadonlyArray }): Operation { const inputId = options?.inputId ?? createInputId() - const user = options?.user ?? this.user + const user = this.actor(options?.user) const sent = options?.context const input = { op: 'continue' as const, @@ -546,7 +548,7 @@ export abstract class SessionRequests< return this.admitting(async () => { const note = options?.note const inputId = options?.inputId ?? createInputId() - const user = options?.user ?? this.user + const user = this.actor(options?.user) // An empty note is no note: the model never gets an empty message. const sent = note?.trim() ? note : undefined const { admission, input } = await this.accept( @@ -624,7 +626,7 @@ export abstract class SessionRequests< } return this.admitting(async () => { const inputId = options.inputId ?? createInputId() - const user = options.user ?? this.user + const user = this.actor(options.user) const runId = options.runId const { admission, input } = await this.accept( inputId, @@ -784,7 +786,7 @@ export abstract class SessionRequests< const { admission, input } = await this.accept( inputId, { op: 'cancel', operationId: options?.operationId }, - options?.user, + this.actor(options?.user), ) if (admission !== 'new') return this.duplicateReceipt(inputId, admission) // `acceptInput` can change the input. @@ -933,7 +935,7 @@ export abstract class SessionRequests< const { admission, input } = await this.accept( inputId, { op: 'cancelInput', inputId: sent }, - user, + this.actor(user), ) if (admission !== 'new') return this.duplicateReceipt(inputId, admission) // `acceptInput` can change the input. @@ -998,7 +1000,7 @@ export abstract class SessionRequests< const { admission, input: accepted } = await this.accept( inputId, { op: 'setDelivery', inputId: sent.inputId, delivery: sent.delivery }, - user, + this.actor(user), ) if (admission !== 'new') return this.duplicateReceipt(inputId, admission) // `acceptInput` can change the input. @@ -1037,7 +1039,7 @@ export abstract class SessionRequests< const connectors = waiting.map(signInConnector) if (waiting.length === 0) return // Another user's credential never answers this sign-in. - const user = stopped?.user ?? this.user + const user = stopped?.user const credentials = this.credentialsOf(user) for (const connector of connectors) { if (!connector || !(await credentials.get(connector))) return @@ -1083,7 +1085,7 @@ export abstract class SessionRequests< this.checkIdle() return } - const user = stopped.user ?? this.user + const user = stopped.user await this.resolve({ resume, inputId: `expired:${interrupts.map((item) => item.id).join(',')}`, diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 95446be54d..17be2cb44c 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -605,10 +605,11 @@ export abstract class SessionState< user?: HarnessUser controlId?: string }): Promise { + const sender = this.actor(user) const { admission, input } = await this.accept( inputId, { op: 'config', key: sent.key, value: sent.value }, - user, + sender, ) if (admission !== 'new') return this.duplicateReceipt(inputId, admission) // `acceptInput` can change the input. @@ -630,7 +631,7 @@ export abstract class SessionState< key, value: checked, inputId, - ...storedUser(user ?? this.user), + ...storedUser(sender), }, ]) this.feed.publish( @@ -666,7 +667,7 @@ export abstract class SessionState< options: ConfigureOptions & { user?: HarnessUser }, ): Promise { const inputId = options.inputId ?? createInputId() - const user = options.user ?? this.user + const user = this.actor(options.user) const { admission, input } = await this.accept( inputId, { op: 'configure', settings: options.settings }, @@ -1132,7 +1133,7 @@ export abstract class SessionState< const isSecret = asked?.secret ?? (!logged || logged.secret === true) const isHidden = ref !== undefined || isSecret if (asked?.secret) this.redactor.add(sent) - const sender = user ?? this.user + const sender = this.actor(user) let accepted try { accepted = await this.accept( @@ -1142,7 +1143,7 @@ export abstract class SessionState< questionId: asking, value: isHidden ? SECRET_ANSWER : sent, }, - user, + sender, isHidden && ref === undefined ? { offer: () => @@ -2089,10 +2090,13 @@ export abstract class SessionState< previous.handoff ? { handoff: previous.handoff } : {}), + // An older interrupted record has no sender. The input of that + // run still has one. A record that names no user, and an input + // that names no user, stay with no user. ...storedUser( - previous !== undefined && previous.runId === runId + (previous !== undefined && previous.runId === runId ? previous.user - : savedInput?.user, + : undefined) ?? savedInput?.user, ), ...(context !== undefined ? { context } : {}), } diff --git a/packages/ai-harness/src/session/turn.ts b/packages/ai-harness/src/session/turn.ts index 44740a91f9..330c9bc9c8 100644 --- a/packages/ai-harness/src/session/turn.ts +++ b/packages/ai-harness/src/session/turn.ts @@ -114,7 +114,7 @@ export abstract class SessionTurn< (target) => this.cancel({ operationId: target.id }), ) this.track(operation) - const user = options.user ?? this.user + const user = this.actor(options.user) // A store write that throws (the log) still ends the command. const inputId = options.inputId ?? createInputId() this.executeCommand(operation, name, input, user, inputId).catch( @@ -493,7 +493,7 @@ export abstract class SessionTurn< } } else if (!isAdded && sent !== undefined) { const messages = isOperator - ? await this.operatorMessages(id, sent, turn.user ?? this.user) + ? await this.operatorMessages(id, sent, turn.user) : [{ ...(await this.userMessage(sent, turn.wake)), id }] await this.addToTurn({ messages }) // A view shows the reply live, as the text of a model, with its @@ -685,7 +685,7 @@ export abstract class SessionTurn< // What plugins and the router see of this turn. A turn that recovery // runs again gets the message its input sent. const sentMessage = turn.message ?? turn.sentMessage - const sender = turn.user ?? this.user + const sender = turn.user const runParent = turn.answers?.runId ?? turn.parentRunId /** `run.handoff`: the handoff of this turn, once it has `routing`. */ let turnHandoff: RunContext['handoff'] = NO_SESSION_CONTEXT.handoff diff --git a/packages/ai-harness/tests/input-sender.test.ts b/packages/ai-harness/tests/input-sender.test.ts index 679756944c..e4bfd454cc 100644 --- a/packages/ai-harness/tests/input-sender.test.ts +++ b/packages/ai-harness/tests/input-sender.test.ts @@ -4,15 +4,18 @@ import { z } from 'zod' import { defineAgent, keyedAdapter, toolDefinition } from '@tanstack/ai' import { NO_OWNER_EPOCH, + memoryLogState, memoryLogStore, memoryPersistence, } from '@tanstack/ai-persistence' import { + InputRejectedError, createHarnessHost, defineCommand, defineHarness, definePlugin, logIdOf, + membersOnly, } from '../src' import { credentialsFor } from '../src/auth' import { after, gate, mockAdapter, stoppedRun, text, toolCall } from './helpers' @@ -109,6 +112,117 @@ describe('the sender of each input', () => { await host.close() }) + it("does not run an offered input that has no user as the owner's user", async () => { + const seen: Array<{ user: string | undefined; key: string }> = [] + const tool = toolDefinition({ + name: 'whoami', + description: 'Who runs this?', + inputSchema: z.object({}), + }).server(async (_input, toolContext) => { + const credential = await toolContext.context.run.credentials.get('acme') + seen.push({ + user: toolContext.context.run.user?.id, + key: credential?.type === 'api_key' ? credential.value : 'none', + }) + return {} + }) + const release = gate() + const state = memoryLogState() + const credentials = memoryCredentialStore() + await credentials.set({ + scope: { threadId: THREAD, userId: 'alice' }, + id: 'acme', + credential: { type: 'api_key', value: 'alice-key' }, + }) + const harness = defineHarness({ + name: 'test/sender-anon', + adapter: mockAdapter([ + after(release.opened, 'hold'), + () => toolCall('whoami', {}, 'c1'), + () => text('ok'), + ]).adapter, + tools: [tool], + }) + const owner = createHarnessHost({ + persistence: { stores: { log: memoryLogStore({ state }) } }, + credentials, + }) + const other = createHarnessHost({ + persistence: { stores: { log: memoryLogStore({ state }) } }, + credentials, + }) + const ownerSession = await owner.open(harness, { + threadId: THREAD, + user: alice, + }) + const otherSession = await other.open(harness, { threadId: THREAD }) + const holding = ownerSession.send('Hold.') + await holding.receipt + const receipt = await otherSession.send('Who?', { inputId: 'in-anon' }) + .receipt + expect(receipt.status).toBe('queued') + release.open() + await holding + expect(await otherSession.settled({ inputId: 'in-anon' })).toMatchObject({ + outcome: 'completed', + }) + expect(seen).toEqual([{ user: undefined, key: 'none' }]) + const inputs = (await memoryLogStore({ state }).read({ logId: LOG })) + .map((entry) => entry.record) + .filter((record) => record.type === 'harness.input') + .map((record) => [record['inputId'], record['user']]) + expect(inputs).toContainEqual(['in-anon', undefined]) + await owner.close() + await other.close() + }) + + it('refuses an offered input that has no user when the owner has access', async () => { + const release = gate() + const state = memoryLogState() + const sessions = memoryPersistence().stores.sessions + const harness = defineHarness({ + name: 'test/sender-access', + adapter: mockAdapter([after(release.opened, 'hold')]).adapter, + }) + const owner = createHarnessHost({ + persistence: { + stores: { log: memoryLogStore({ state }), sessions }, + }, + access: { getUser: () => null, rules: membersOnly() }, + }) + const other = createHarnessHost({ + persistence: { + stores: { log: memoryLogStore({ state }), sessions }, + }, + }) + const ownerSession = await owner.open(harness, { + threadId: THREAD, + user: alice, + }) + const holding = ownerSession.send('Hold.') + await holding.receipt + const otherSession = await other.open(harness, { threadId: THREAD }) + const sent = otherSession.send('Who?', { inputId: 'in-anon' }) + sent.then( + () => {}, + () => {}, + ) + expect((await sent.receipt).status).toBe('queued') + await expect( + otherSession.settled({ inputId: 'in-anon' }), + ).rejects.toMatchObject({ + name: 'InputRejectedError', + receipt: { reason: 'no_user' }, + }) + await expect(Promise.resolve(sent)).rejects.toBeInstanceOf( + InputRejectedError, + ) + release.open() + await holding + await owner.close() + await other.close() + }) + it("builds the model call of bob's input with bob's key", async () => { const keys: Array = [] const adapter = keyedAdapter('acme', (key) => {