Skip to content

Commit aea0509

Browse files
author
Antoni T
committed
examples/agent: keep the approval signature across the turn
The worker signs every approval it asks for. It has to: the conversation lives in the terminal, so an answer arrives as a claim the client makes about something the operator supposedly did, and on that claim rests the write access the command is about to be given. The AI SDK checks the signature before it will run the tool call, which is what stops an approval that was never issued. The terminal UI throws the signature away. Recording an answer replaces part.approval rather than adding to it, so what goes back is unsigned and the turn dies with AI_InvalidToolApprovalSignatureError: missing signature. Every published @ai-sdk/tui through 1.0.52 does this, so every approval in this example failed, which is to say the flow the example exists to show did not work. Wrap the transport instead of forking the UI. It notes the signature on each approval request passing through, and restores it on the answer that comes back. This is a repair, not a loophole: a signature is not a secret but a MAC only the worker can produce or check, the cache can only supply one the worker itself issued for that exact approval id, and carrying it across a turn it was always meant to survive gives the client nothing it did not already have. A forged approval still has nothing to present.
1 parent 58ae7dc commit aea0509

4 files changed

Lines changed: 255 additions & 3 deletions

File tree

examples/agent/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,27 @@ layer. A recognized read that turns up wanting write access did not
107107
come that way, and it is narrowed back to read-only, which costs it
108108
nothing the matcher says it needed.
109109

110+
## The approval has to survive the trip
111+
112+
The conversation lives in the terminal, not in the Durable Object. An
113+
answer to an approval therefore arrives as a claim the client makes
114+
about something you supposedly did, and on that claim rests the write
115+
access the command is about to get. So the worker signs every approval
116+
it asks for, with a per-object key it keeps in storage, and the AI SDK
117+
checks the signature before it will run the tool call. An approval that
118+
was never issued has nothing to present.
119+
120+
The terminal UI drops the signature. Recording your answer replaces the
121+
approval rather than adding to it, so what goes back is unsigned and
122+
the turn dies with `missing signature`. That is true of every published
123+
`@ai-sdk/tui` through 1.0.52, so the client wraps its transport to
124+
remember the signatures it saw and put them back:
125+
[`cli/approval-signatures.mjs`](cli/approval-signatures.mjs). The
126+
repair belongs in the transport because a signature is not a secret —
127+
it is a MAC only the worker can produce or check — and carrying one
128+
across a turn it was always meant to survive gives the client nothing
129+
it did not already have.
130+
110131
## Running it
111132

112133
```bash
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* A chat transport that remembers the signatures on the approvals it
3+
* saw, and puts them back on the answers it sends.
4+
*
5+
* The worker signs every approval it asks for, because the conversation
6+
* lives in this process: an answer arrives as a claim the client makes
7+
* about something you supposedly did, and the signature is what makes
8+
* that claim checkable. The AI SDK verifies it before it will run the
9+
* tool call.
10+
*
11+
* The terminal UI drops it. Recording an answer replaces the whole
12+
* approval object rather than adding to it:
13+
*
14+
* part.approval = { id: request.approvalId, approved, ...reason };
15+
*
16+
* so the signature the worker issued is gone by the time the answer is
17+
* posted back, and the turn dies with
18+
* `AI_InvalidToolApprovalSignatureError: missing signature`. That is
19+
* true of every published @ai-sdk/tui through 1.0.52.
20+
*
21+
* A signature is not a secret — it is a MAC the worker issued over an
22+
* approval id, and only the worker can make or check one. Carrying it
23+
* across a turn it was always meant to survive grants this client
24+
* nothing it did not already have, which is why the repair belongs
25+
* here, in the transport, rather than in a fork of the UI. The cache
26+
* only ever supplies a signature the worker itself sent for that exact
27+
* approval id, so a forged approval still has nothing to present.
28+
*/
29+
30+
/**
31+
* Wrap a chat transport so approval signatures survive the round trip.
32+
*
33+
* @template {{ sendMessages: (options: any) => Promise<ReadableStream<any>> }} T
34+
* @param {T} transport
35+
* @returns {T}
36+
*/
37+
export function withApprovalSignatures(transport) {
38+
/** @type {Map<string, string>} */
39+
const signatures = new Map();
40+
41+
return Object.create(transport, {
42+
sendMessages: {
43+
value: async (options) => {
44+
for (const message of options.messages ?? []) {
45+
for (const part of message.parts ?? []) restore(part, signatures);
46+
}
47+
const stream = await transport.sendMessages(options);
48+
return stream.pipeThrough(remember(signatures));
49+
},
50+
},
51+
});
52+
}
53+
54+
/**
55+
* Re-attach the signature for an answered approval, if we have one and
56+
* the answer is missing it.
57+
*/
58+
function restore(part, signatures) {
59+
const approval = part?.approval;
60+
if (!approval || approval.signature !== undefined) return;
61+
const signature = signatures.get(approval.id);
62+
if (signature !== undefined) approval.signature = signature;
63+
}
64+
65+
/**
66+
* Note the signature on every approval the worker asks for, passing the
67+
* stream through untouched.
68+
*/
69+
function remember(signatures) {
70+
return new TransformStream({
71+
transform(chunk, controller) {
72+
if (chunk?.type === "tool-approval-request" && chunk.signature !== undefined) {
73+
signatures.set(chunk.approvalId, chunk.signature);
74+
}
75+
controller.enqueue(chunk);
76+
},
77+
});
78+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from "vitest";
2+
import { withApprovalSignatures } from "./approval-signatures.mjs";
3+
4+
function streamOf(chunks) {
5+
return new ReadableStream({
6+
start(controller) {
7+
for (const chunk of chunks) controller.enqueue(chunk);
8+
controller.close();
9+
},
10+
});
11+
}
12+
13+
async function drain(stream) {
14+
const reader = stream.getReader();
15+
const out = [];
16+
for (;;) {
17+
const { done, value } = await reader.read();
18+
if (done) return out;
19+
out.push(value);
20+
}
21+
}
22+
23+
// Stands in for the worker: hands back whatever chunks the test names
24+
// and remembers the messages it was asked to send.
25+
function fakeTransport(chunks = []) {
26+
const transport = {
27+
sent: [],
28+
async sendMessages(options) {
29+
transport.sent.push(options);
30+
return streamOf(chunks);
31+
},
32+
async reconnectToStream() {
33+
return null;
34+
},
35+
};
36+
return transport;
37+
}
38+
39+
const request = {
40+
type: "tool-approval-request",
41+
approvalId: "aitxt-1",
42+
toolCallId: "call-1",
43+
signature: "sig-1",
44+
};
45+
46+
// The exact mutation @ai-sdk/tui performs on a "yes": the whole
47+
// approval object is replaced, so the signature the worker issued does
48+
// not survive. See applyToolApprovalResponse in @ai-sdk/tui.
49+
function answerLikeTheTUI(part, approved) {
50+
part.state = "approval-responded";
51+
part.approval = { id: part.approval.id, approved };
52+
}
53+
54+
function respondedMessage(approved = true) {
55+
const part = {
56+
type: "tool-exec",
57+
toolCallId: "call-1",
58+
state: "approval-requested",
59+
input: { command: "rm /workspace/x" },
60+
approval: { id: "aitxt-1", signature: "sig-1" },
61+
};
62+
answerLikeTheTUI(part, approved);
63+
return { id: "m1", role: "assistant", parts: [part] };
64+
}
65+
66+
describe("withApprovalSignatures", () => {
67+
it("passes the worker's chunks through untouched", async () => {
68+
const inner = fakeTransport([{ type: "start" }, request]);
69+
const stream = await withApprovalSignatures(inner).sendMessages({ messages: [] });
70+
expect(await drain(stream)).toEqual([{ type: "start" }, request]);
71+
});
72+
73+
it("puts back the signature the terminal UI dropped", async () => {
74+
const inner = fakeTransport([request]);
75+
const transport = withApprovalSignatures(inner);
76+
77+
// Turn one: the worker asks, and the signature goes by on the wire.
78+
await drain(await transport.sendMessages({ messages: [] }));
79+
80+
// Turn two: the answer comes back without it.
81+
await transport.sendMessages({ messages: [respondedMessage(true)] });
82+
83+
const part = inner.sent[1].messages[0].parts[0];
84+
expect(part.approval).toEqual({ id: "aitxt-1", approved: true, signature: "sig-1" });
85+
});
86+
87+
it("signs a refusal too, so a no is as checkable as a yes", async () => {
88+
const inner = fakeTransport([request]);
89+
const transport = withApprovalSignatures(inner);
90+
await drain(await transport.sendMessages({ messages: [] }));
91+
92+
await transport.sendMessages({ messages: [respondedMessage(false)] });
93+
94+
const part = inner.sent[1].messages[0].parts[0];
95+
expect(part.approval).toMatchObject({ approved: false, signature: "sig-1" });
96+
});
97+
98+
it("leaves an approval alone when it never saw a signature for it", async () => {
99+
const inner = fakeTransport([]);
100+
const transport = withApprovalSignatures(inner);
101+
102+
await transport.sendMessages({ messages: [respondedMessage(true)] });
103+
104+
const part = inner.sent[0].messages[0].parts[0];
105+
expect(part.approval).toEqual({ id: "aitxt-1", approved: true });
106+
});
107+
108+
it("does not overwrite a signature that survived", async () => {
109+
const inner = fakeTransport([request]);
110+
const transport = withApprovalSignatures(inner);
111+
await drain(await transport.sendMessages({ messages: [] }));
112+
113+
const message = respondedMessage(true);
114+
message.parts[0].approval.signature = "sig-from-elsewhere";
115+
await transport.sendMessages({ messages: [message] });
116+
117+
expect(inner.sent[1].messages[0].parts[0].approval.signature).toBe("sig-from-elsewhere");
118+
});
119+
120+
it("keeps signatures apart when a turn asks about two commands", async () => {
121+
const second = { ...request, approvalId: "aitxt-2", toolCallId: "call-2", signature: "sig-2" };
122+
const inner = fakeTransport([request, second]);
123+
const transport = withApprovalSignatures(inner);
124+
await drain(await transport.sendMessages({ messages: [] }));
125+
126+
const message = respondedMessage(true);
127+
const other = {
128+
type: "tool-exec",
129+
toolCallId: "call-2",
130+
state: "approval-requested",
131+
approval: { id: "aitxt-2", signature: "sig-2" },
132+
};
133+
answerLikeTheTUI(other, true);
134+
message.parts.push(other);
135+
await transport.sendMessages({ messages: [message] });
136+
137+
const parts = inner.sent[1].messages[0].parts;
138+
expect(parts[0].approval.signature).toBe("sig-1");
139+
expect(parts[1].approval.signature).toBe("sig-2");
140+
});
141+
142+
it("delegates the rest of the transport", async () => {
143+
const inner = fakeTransport([]);
144+
expect(await withApprovalSignatures(inner).reconnectToStream({})).toBeNull();
145+
});
146+
});

examples/agent/cli/chat.mjs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@
1111
* There is deliberately very little here. The AI SDK's terminal UI
1212
* already knows how to render a pending approval and send the answer
1313
* back, and the worker speaks the UI message stream that
14-
* `DefaultChatTransport` posts to, so the whole client is a URL.
14+
* `DefaultChatTransport` posts to, so the client is a URL and one
15+
* wrapper.
1516
*
1617
* The conversation lives in this process rather than on the server,
1718
* which is why the worker signs its approval requests: an approval
1819
* comes back as a claim this client makes about something you
1920
* supposedly did, and the signature is what makes that claim checkable
20-
* rather than merely plausible.
21+
* rather than merely plausible. The wrapper is there because the
22+
* terminal UI throws that signature away when it records your answer;
23+
* ./approval-signatures.mjs explains what it does about it.
2124
*
2225
* The worker has to be running first (`npm run dev`, default
2326
* http://127.0.0.1:8787). Point somewhere else with --worker or the
@@ -39,6 +42,7 @@
3942
import { argv, env, exit, stderr } from "node:process";
4043
import { runAgentTUI } from "@ai-sdk/tui";
4144
import { DefaultChatTransport } from "ai";
45+
import { withApprovalSignatures } from "./approval-signatures.mjs";
4246

4347
const { workerUrl, name, title } = parseArgs(argv.slice(2));
4448

@@ -55,7 +59,10 @@ const api = new URL(`/c/${encodeURIComponent(name)}/agent`, base).toString();
5559
stderr.write(`talking to ${api}\n`);
5660

5761
await runAgentTUI({
58-
transport: new DefaultChatTransport({ api }),
62+
// The wrapper is not decoration: the terminal UI drops the signature
63+
// off an approval when it records your answer, and the worker will
64+
// not run an unsigned one. See ./approval-signatures.mjs.
65+
transport: withApprovalSignatures(new DefaultChatTransport({ api })),
5966
title: title ?? `computer-agent · ${name}`,
6067
});
6168

0 commit comments

Comments
 (0)