broapp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ai/host/adapter.ts +4 -1
- package/src/ai/host/create-ai.ts +72 -6
- package/src/ai/host/fake.ts +11 -4
- package/src/ai/host/from-contract.ts +35 -15
- package/src/ai/host/index.ts +6 -1
- package/src/ai/host/registry.ts +19 -8
- package/src/ai/host/run.ts +247 -27
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +59 -53
- package/src/ai/react/AiChat.tsx +49 -1
- package/src/ai/react/AiSettings.tsx +8 -2
- package/src/ai/react/ai.css +14 -0
- package/src/ai/react/index.tsx +3 -0
- package/src/ai/react/use-ai-chat.ts +9 -1
- package/src/ai/shared/contract.ts +110 -1
- package/src/ai/shared/index.ts +3 -0
- package/src/ai/shared/types.check.ts +30 -2
- package/src/ai/shared/types.ts +62 -2
- package/src/host/app.ts +99 -16
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +22 -2
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/shared/contract.ts +49 -6
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +56 -2
- package/src/shared/index.ts +6 -1
- package/src/shared/schema.ts +16 -0
|
@@ -34,7 +34,7 @@ const providerInfo = s.object({
|
|
|
34
34
|
label: s.string(),
|
|
35
35
|
local: s.boolean(),
|
|
36
36
|
needs: s.object({
|
|
37
|
-
apiKey: s.
|
|
37
|
+
apiKey: s.enum(['required', 'optional', 'none']),
|
|
38
38
|
baseUrl: s.enum(['required', 'optional', 'none']),
|
|
39
39
|
}),
|
|
40
40
|
defaultBaseUrl: s.nullable(s.string()),
|
|
@@ -55,6 +55,53 @@ const chatTurn = s.object({
|
|
|
55
55
|
content: s.string({ max: 20_000 }),
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* One image on a turn. `data` is base64 without the `data:` prefix.
|
|
60
|
+
*
|
|
61
|
+
* The bounds are the browser's contract as much as the host's: the panel
|
|
62
|
+
* downscales before it sends, and four images of two million characters still
|
|
63
|
+
* fit inside one Brobridge frame with room to spare.
|
|
64
|
+
*/
|
|
65
|
+
const chatFile = s.object({
|
|
66
|
+
name: s.string({ max: 200 }),
|
|
67
|
+
mediaType: s.string({ pattern: /image\/(png|jpeg|gif|webp)/ }),
|
|
68
|
+
data: s.string({ min: 1, max: 2_000_000 }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** A conversation identifier. The host chooses it; the browser only echoes it. */
|
|
72
|
+
const threadId = s.string({ pattern: /[A-Za-z0-9_-]{8,64}/ });
|
|
73
|
+
|
|
74
|
+
/** A conversation, without its messages. */
|
|
75
|
+
const thread = s.object({
|
|
76
|
+
id: threadId,
|
|
77
|
+
title: s.string({ max: 120 }),
|
|
78
|
+
/** Null means "whatever Settings says". */
|
|
79
|
+
modelId: s.nullable(s.string({ max: 200 })),
|
|
80
|
+
createdAt: s.number(),
|
|
81
|
+
updatedAt: s.number(),
|
|
82
|
+
messageCount: s.number({ int: true, min: 0 }),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One stored UI message. Parts are the AI SDK's; the host stores, never
|
|
87
|
+
* interprets.
|
|
88
|
+
*
|
|
89
|
+
* `s.unknown()` is normally forbidden on an input, because the point of an
|
|
90
|
+
* input schema is that browser-supplied data is untrusted. It is right here
|
|
91
|
+
* for the one reason that exempts it: nothing on the host ever reads inside a
|
|
92
|
+
* part. They are written to SQLite as JSON and handed back to the same browser
|
|
93
|
+
* that sent them, so the shape the host would be validating is a shape only
|
|
94
|
+
* the AI SDK understands and only the AI SDK consumes. What is still bounded
|
|
95
|
+
* is the *amount*: 200 parts to a message, 200 messages to a save, and a byte
|
|
96
|
+
* ceiling on the whole save in `threads.ts`.
|
|
97
|
+
*/
|
|
98
|
+
const storedMessage = s.object({
|
|
99
|
+
id: s.string({ max: 200 }),
|
|
100
|
+
role: s.enum(['user', 'assistant', 'system']),
|
|
101
|
+
parts: s.array(s.unknown(), { max: 200 }),
|
|
102
|
+
metadata: s.optional(s.unknown()),
|
|
103
|
+
});
|
|
104
|
+
|
|
58
105
|
/**
|
|
59
106
|
* One stream event, flat because the validator has no unions.
|
|
60
107
|
*
|
|
@@ -72,6 +119,10 @@ const chatEvent = s.object({
|
|
|
72
119
|
output: s.optional(s.unknown()),
|
|
73
120
|
denied: s.optional(s.boolean()),
|
|
74
121
|
permission: s.optional(s.enum(['read', 'confirm'])),
|
|
122
|
+
requestId: s.optional(s.string()),
|
|
123
|
+
releaseId: s.optional(s.string()),
|
|
124
|
+
argumentsHash: s.optional(s.string()),
|
|
125
|
+
expiresAt: s.optional(s.number()),
|
|
75
126
|
inputTokens: s.optional(s.number()),
|
|
76
127
|
outputTokens: s.optional(s.number()),
|
|
77
128
|
code: s.optional(s.string()),
|
|
@@ -119,6 +170,56 @@ export const aiContract = defineContract({
|
|
|
119
170
|
output: s.object({ accepted: s.boolean() }),
|
|
120
171
|
summary: 'Answer a confirm event. `accepted` is false when no run is waiting on that call.',
|
|
121
172
|
},
|
|
173
|
+
// Conversations are the user's own data, so every route below answers even
|
|
174
|
+
// when no provider is configured: somebody who has just removed their key
|
|
175
|
+
// is still entitled to read and delete what they wrote.
|
|
176
|
+
'ai.threadsList': {
|
|
177
|
+
input: s.void(),
|
|
178
|
+
output: s.object({ threads: s.array(thread, { max: 500 }) }),
|
|
179
|
+
summary: 'Every stored conversation, most recently changed first.',
|
|
180
|
+
},
|
|
181
|
+
'ai.threadsCreate': {
|
|
182
|
+
input: s.object({
|
|
183
|
+
title: s.optional(s.string({ max: 120 })),
|
|
184
|
+
modelId: s.optional(s.nullable(s.string({ max: 200 }))),
|
|
185
|
+
}),
|
|
186
|
+
output: thread,
|
|
187
|
+
summary: 'Start a conversation. Without a title it is named after its first message.',
|
|
188
|
+
},
|
|
189
|
+
'ai.threadsGet': {
|
|
190
|
+
input: s.object({ id: threadId }),
|
|
191
|
+
output: s.object({ thread, messages: s.array(storedMessage, { max: 200 }) }),
|
|
192
|
+
summary: 'One conversation and its messages.',
|
|
193
|
+
},
|
|
194
|
+
'ai.threadsSave': {
|
|
195
|
+
input: s.object({
|
|
196
|
+
id: threadId,
|
|
197
|
+
messages: s.array(storedMessage, { max: 200 }),
|
|
198
|
+
title: s.optional(s.string({ max: 120 })),
|
|
199
|
+
}),
|
|
200
|
+
output: thread,
|
|
201
|
+
summary: 'Replace the messages of a conversation, whole.',
|
|
202
|
+
},
|
|
203
|
+
'ai.threadsUpdate': {
|
|
204
|
+
input: s.object({
|
|
205
|
+
id: threadId,
|
|
206
|
+
title: s.optional(s.string({ max: 120 })),
|
|
207
|
+
// Null puts the conversation back on whatever Settings says.
|
|
208
|
+
modelId: s.optional(s.nullable(s.string({ max: 200 }))),
|
|
209
|
+
}),
|
|
210
|
+
output: thread,
|
|
211
|
+
summary: 'Rename a conversation, or give it a model of its own.',
|
|
212
|
+
},
|
|
213
|
+
'ai.threadsDelete': {
|
|
214
|
+
input: s.object({ id: threadId }),
|
|
215
|
+
output: s.object({ deleted: s.boolean() }),
|
|
216
|
+
summary: 'Delete one conversation and its messages.',
|
|
217
|
+
},
|
|
218
|
+
'ai.threadsClear': {
|
|
219
|
+
input: s.void(),
|
|
220
|
+
output: s.object({ deleted: s.number({ int: true, min: 0 }) }),
|
|
221
|
+
summary: 'Delete every conversation.',
|
|
222
|
+
},
|
|
122
223
|
},
|
|
123
224
|
streams: {
|
|
124
225
|
'ai.chat': {
|
|
@@ -127,6 +228,14 @@ export const aiContract = defineContract({
|
|
|
127
228
|
message: s.string({ min: 1, max: 20_000 }),
|
|
128
229
|
refs: s.array(s.string({ max: 200 }), { max: 50 }),
|
|
129
230
|
history: s.array(chatTurn, { max: 100 }),
|
|
231
|
+
// Images travel with the turn they arrive on. History keeps a
|
|
232
|
+
// placeholder instead, because a transcript of base64 would not fit.
|
|
233
|
+
files: s.optional(s.array(chatFile, { max: 4 })),
|
|
234
|
+
// The model for this turn only, and only *within* the configured
|
|
235
|
+
// provider. A provider is never overridden per turn: a different
|
|
236
|
+
// provider means a different key and a different answer to "does this
|
|
237
|
+
// leave my computer", and that stays a Settings decision.
|
|
238
|
+
modelId: s.optional(s.string({ max: 200 })),
|
|
130
239
|
}),
|
|
131
240
|
event: chatEvent,
|
|
132
241
|
summary: 'One chat turn. Emits text, tool calls, confirmations and usage.',
|
package/src/ai/shared/index.ts
CHANGED
|
@@ -5,9 +5,22 @@
|
|
|
5
5
|
* without changing the matching interface in `types.ts` fails `tsc` instead of
|
|
6
6
|
* failing later, in the browser, as a shape that is almost right.
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
OperationInput,
|
|
10
|
+
OperationOutput,
|
|
11
|
+
StreamEvent,
|
|
12
|
+
StreamParams,
|
|
13
|
+
} from '../../shared/contract.ts';
|
|
9
14
|
import type { AiContract } from './contract.ts';
|
|
10
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
AiSettings,
|
|
17
|
+
BroappModel,
|
|
18
|
+
ChatEvent,
|
|
19
|
+
ChatFile,
|
|
20
|
+
ProviderInfo,
|
|
21
|
+
StoredMessage,
|
|
22
|
+
Thread,
|
|
23
|
+
} from './types.ts';
|
|
11
24
|
|
|
12
25
|
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
13
26
|
? true
|
|
@@ -37,6 +50,21 @@ void providerMatch;
|
|
|
37
50
|
const chatEventMatch: Equal<StreamEvent<AiContract, 'ai.chat'>, ChatEvent> = true;
|
|
38
51
|
void chatEventMatch;
|
|
39
52
|
|
|
53
|
+
const chatFileMatch: Equal<
|
|
54
|
+
NonNullable<StreamParams<AiContract, 'ai.chat'>['files']>[number],
|
|
55
|
+
ChatFile
|
|
56
|
+
> = true;
|
|
57
|
+
void chatFileMatch;
|
|
58
|
+
|
|
59
|
+
const threadMatch: Equal<OperationOutput<AiContract, 'ai.threadsCreate'>, Thread> = true;
|
|
60
|
+
void threadMatch;
|
|
61
|
+
|
|
62
|
+
const storedMessageMatch: Equal<
|
|
63
|
+
OperationOutput<AiContract, 'ai.threadsGet'>['messages'][number],
|
|
64
|
+
StoredMessage
|
|
65
|
+
> = true;
|
|
66
|
+
void storedMessageMatch;
|
|
67
|
+
|
|
40
68
|
// The update route is the only one that takes a partial: every field optional,
|
|
41
69
|
// so a browser can change one setting without restating the rest.
|
|
42
70
|
const updateAcceptsNothing: OperationInput<AiContract, 'ai.settingsUpdate'> = {};
|
package/src/ai/shared/types.ts
CHANGED
|
@@ -31,7 +31,10 @@ export interface ProviderInfo {
|
|
|
31
31
|
label: string;
|
|
32
32
|
/** True when requests stay on this machine with the current settings. */
|
|
33
33
|
local: boolean;
|
|
34
|
-
needs: {
|
|
34
|
+
needs: {
|
|
35
|
+
apiKey: 'required' | 'optional' | 'none';
|
|
36
|
+
baseUrl: 'required' | 'optional' | 'none';
|
|
37
|
+
};
|
|
35
38
|
defaultBaseUrl: string | null;
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -58,6 +61,56 @@ export interface ChatTurn {
|
|
|
58
61
|
content: string;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* One image sent with a chat turn.
|
|
66
|
+
*
|
|
67
|
+
* `data` is base64 with no `data:` prefix, at most 2,000,000 characters, and
|
|
68
|
+
* `mediaType` is one of `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
|
|
69
|
+
* At most four travel with one message, and they travel only with the message
|
|
70
|
+
* they arrive on: a later turn's `history` keeps the line
|
|
71
|
+
* `[image: <name>]` in place of the image itself, because a transcript of
|
|
72
|
+
* base64 would not fit inside the contract's bound on a turn.
|
|
73
|
+
*/
|
|
74
|
+
export interface ChatFile {
|
|
75
|
+
name: string;
|
|
76
|
+
mediaType: string;
|
|
77
|
+
data: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A stored conversation, without its messages.
|
|
82
|
+
*
|
|
83
|
+
* `modelId` is null for a conversation that follows Settings, and a model id
|
|
84
|
+
* for one that has been pinned to a model of its own. The provider is never
|
|
85
|
+
* part of a conversation: it is a Settings decision, because changing it
|
|
86
|
+
* changes which key is used and whether anything leaves the computer.
|
|
87
|
+
*/
|
|
88
|
+
export interface Thread {
|
|
89
|
+
id: string;
|
|
90
|
+
title: string;
|
|
91
|
+
modelId: string | null;
|
|
92
|
+
createdAt: number;
|
|
93
|
+
updatedAt: number;
|
|
94
|
+
messageCount: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One message as it is stored.
|
|
99
|
+
*
|
|
100
|
+
* `parts` are the AI SDK's own message parts. The host writes them as JSON and
|
|
101
|
+
* hands them back unread — it has no opinion about what a part is, which is
|
|
102
|
+
* why the type is `unknown[]` rather than a copy of the SDK's union that would
|
|
103
|
+
* drift from it. One thing the host *does* change on the way in: a `file` part
|
|
104
|
+
* becomes the text `[image: name]`, because a data URL in SQLite would be a
|
|
105
|
+
* copy of the image nobody asked to keep.
|
|
106
|
+
*/
|
|
107
|
+
export interface StoredMessage {
|
|
108
|
+
id: string;
|
|
109
|
+
role: 'user' | 'assistant' | 'system';
|
|
110
|
+
parts: unknown[];
|
|
111
|
+
metadata?: unknown;
|
|
112
|
+
}
|
|
113
|
+
|
|
61
114
|
/**
|
|
62
115
|
* One event on the `ai.chat` stream. Flat on purpose: the `s` validator has
|
|
63
116
|
* no unions, so the discriminant is `type` and the other fields are
|
|
@@ -65,7 +118,8 @@ export interface ChatTurn {
|
|
|
65
118
|
*
|
|
66
119
|
* text text
|
|
67
120
|
* tool-call callId, tool, input, permission
|
|
68
|
-
* confirm callId, tool, input
|
|
121
|
+
* confirm callId, tool, input, requestId, releaseId, argumentsHash,
|
|
122
|
+
* expiresAt (waits for ai.chatConfirm)
|
|
69
123
|
* tool-result callId, tool, output, denied?
|
|
70
124
|
* usage inputTokens, outputTokens
|
|
71
125
|
* done —
|
|
@@ -80,6 +134,12 @@ export interface ChatEvent {
|
|
|
80
134
|
output?: unknown;
|
|
81
135
|
denied?: boolean;
|
|
82
136
|
permission?: ToolPermission;
|
|
137
|
+
/** On `confirm`: what the gate is waiting on, so an answer can name it. */
|
|
138
|
+
requestId?: string;
|
|
139
|
+
releaseId?: string;
|
|
140
|
+
argumentsHash?: string;
|
|
141
|
+
/** On `confirm`: when the question stops waiting, so the card can count down. */
|
|
142
|
+
expiresAt?: number;
|
|
83
143
|
inputTokens?: number;
|
|
84
144
|
outputTokens?: number;
|
|
85
145
|
code?: string;
|
package/src/host/app.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { BridgeStream } from '@brobridgejs/core';
|
|
|
16
16
|
|
|
17
17
|
import type {
|
|
18
18
|
AnyContract,
|
|
19
|
+
Effect,
|
|
19
20
|
OperationInput,
|
|
20
21
|
OperationName,
|
|
21
22
|
OperationOutput,
|
|
@@ -23,10 +24,18 @@ import type {
|
|
|
23
24
|
StreamName,
|
|
24
25
|
StreamParams,
|
|
25
26
|
} from '../shared/contract.ts';
|
|
26
|
-
import { assertNoReservedRoutes, splitRoute } from '../shared/contract.ts';
|
|
27
|
-
import {
|
|
27
|
+
import { assertNoReservedRoutes, effectOf, splitRoute } from '../shared/contract.ts';
|
|
28
|
+
import {
|
|
29
|
+
INTERNAL_ERROR_MESSAGE,
|
|
30
|
+
isPublicBridgeError,
|
|
31
|
+
isPublicError,
|
|
32
|
+
PublicError,
|
|
33
|
+
} from '../shared/errors.ts';
|
|
28
34
|
import { encodeEvent } from '../shared/ndjson.ts';
|
|
29
|
-
import {
|
|
35
|
+
import { isValidationError } from '../shared/schema.ts';
|
|
36
|
+
|
|
37
|
+
import { createGate } from './gate.ts';
|
|
38
|
+
import type { Channel, Envelope, ExecutionMode, Gate } from './gate.ts';
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* What an operation handler is told about its caller.
|
|
@@ -41,6 +50,14 @@ import { ValidationError } from '../shared/schema.ts';
|
|
|
41
50
|
export interface CallContext {
|
|
42
51
|
/** The route name, for logging. */
|
|
43
52
|
readonly route: string;
|
|
53
|
+
/** Correlates this call with the gate's record and any approval it needed. */
|
|
54
|
+
readonly requestId: string;
|
|
55
|
+
/** Who asked, as the adapter that received the request said so. */
|
|
56
|
+
readonly channel: Channel;
|
|
57
|
+
/** Free text naming the caller: 'tab', 'ai:<runId>', 'mcp:<client>'. */
|
|
58
|
+
readonly caller: string;
|
|
59
|
+
/** `preview` means the data directory is a copy and nothing may leave the machine. */
|
|
60
|
+
readonly mode: ExecutionMode;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/** A unary operation implementation. */
|
|
@@ -81,6 +98,12 @@ export interface HostLogger {
|
|
|
81
98
|
/** Options for {@link createHostApp}. */
|
|
82
99
|
export interface HostAppOptions {
|
|
83
100
|
readonly logger?: HostLogger;
|
|
101
|
+
/**
|
|
102
|
+
* The gate every call passes. An application that does not supply one gets a
|
|
103
|
+
* live gate for an unreleased application: the policy still applies, there is
|
|
104
|
+
* simply nothing recording it and no release to bind an approval to.
|
|
105
|
+
*/
|
|
106
|
+
readonly gate?: Gate;
|
|
84
107
|
}
|
|
85
108
|
|
|
86
109
|
/** A contract with implementations attached, ready to mount on a bridge. */
|
|
@@ -106,12 +129,22 @@ export interface HostApp<C extends AnyContract> {
|
|
|
106
129
|
* A route that is a stream, or one no handler implements, is a programming
|
|
107
130
|
* error rather than a call failure, so it throws `TypeError` at the call
|
|
108
131
|
* site instead of rejecting.
|
|
132
|
+
*
|
|
133
|
+
* The envelope is required, and required to be built by the adapter making
|
|
134
|
+
* the call rather than passed through from whatever asked it to. That is
|
|
135
|
+
* what stops a model from invoking an operation as the user.
|
|
109
136
|
*/
|
|
110
|
-
invoke<K extends OperationName<C>>(
|
|
137
|
+
invoke<K extends OperationName<C>>(
|
|
138
|
+
name: K,
|
|
139
|
+
input: unknown,
|
|
140
|
+
envelope: Envelope,
|
|
141
|
+
): Promise<OperationOutput<C, K>>;
|
|
111
142
|
/** Abort every stream this app currently has open. Called during shutdown. */
|
|
112
143
|
abortAll(reason: string): void;
|
|
113
144
|
/** How many streams are running right now. */
|
|
114
145
|
readonly activeStreams: number;
|
|
146
|
+
/** The gate this application's calls pass through. */
|
|
147
|
+
readonly gate: Gate;
|
|
115
148
|
}
|
|
116
149
|
|
|
117
150
|
/**
|
|
@@ -141,6 +174,12 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
141
174
|
options: HostAppOptions = {},
|
|
142
175
|
): HostApp<C> {
|
|
143
176
|
const logger: HostLogger = options.logger ?? console;
|
|
177
|
+
// An application that was not given a gate still has one. There is no
|
|
178
|
+
// ungated path into a handler, so the only question a missing option answers
|
|
179
|
+
// is what the records say, not whether the policy applies.
|
|
180
|
+
const gate: Gate =
|
|
181
|
+
options.gate ??
|
|
182
|
+
createGate({ appId: 'app', releaseId: 'unreleased', mode: 'live', logger });
|
|
144
183
|
const operations = new Map<string, OperationHandler<C, never>>();
|
|
145
184
|
const streams = new Map<string, StreamHandlerFor<C, never>>();
|
|
146
185
|
const running = new Set<AbortController>();
|
|
@@ -159,13 +198,12 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
159
198
|
* call is not more trusted than a browser call just because it originates
|
|
160
199
|
* inside the host — so there is one implementation and two callers.
|
|
161
200
|
*/
|
|
162
|
-
async function runOperation(route: string, raw: unknown): Promise<unknown> {
|
|
201
|
+
async function runOperation(route: string, raw: unknown, envelope: Envelope): Promise<unknown> {
|
|
163
202
|
const spec = contract.operations[route];
|
|
164
203
|
const handler = operations.get(route);
|
|
165
204
|
if (spec === undefined || handler === undefined) {
|
|
166
205
|
throw new TypeError(`operation ${JSON.stringify(route)} has no implementation`);
|
|
167
206
|
}
|
|
168
|
-
const context: CallContext = { route };
|
|
169
207
|
let input: unknown;
|
|
170
208
|
try {
|
|
171
209
|
input = spec.input.parse(raw);
|
|
@@ -173,13 +211,27 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
173
211
|
// A validation message names a field and a constraint from the contract
|
|
174
212
|
// the browser already has. It carries nothing the caller did not send,
|
|
175
213
|
// so it is safe to return and useful to see.
|
|
214
|
+
//
|
|
215
|
+
// This is deliberately before the gate. A call that never had valid
|
|
216
|
+
// arguments asked nobody for anything, so there is nothing to approve and
|
|
217
|
+
// nothing worth recording.
|
|
176
218
|
throw new PublicError(
|
|
177
219
|
'invalid_input',
|
|
178
|
-
cause
|
|
220
|
+
isValidationError(cause) ? cause.message : 'invalid input',
|
|
179
221
|
).toBridgeError();
|
|
180
222
|
}
|
|
223
|
+
const context: CallContext = {
|
|
224
|
+
route,
|
|
225
|
+
requestId: envelope.requestId,
|
|
226
|
+
channel: envelope.channel,
|
|
227
|
+
caller: envelope.caller,
|
|
228
|
+
mode: gate.mode === 'preview' || envelope.mode === 'preview' ? 'preview' : 'live',
|
|
229
|
+
};
|
|
181
230
|
try {
|
|
182
|
-
const output = await
|
|
231
|
+
const output = await gate.guard(
|
|
232
|
+
{ ...envelope, route, effect: spec.effect ?? envelope.effectHint ?? 'write', input },
|
|
233
|
+
() => Promise.resolve(handler(input as never, context)),
|
|
234
|
+
);
|
|
183
235
|
return spec.output.parse(output);
|
|
184
236
|
} catch (cause) {
|
|
185
237
|
throw wrap(cause, route, logger);
|
|
@@ -201,7 +253,7 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
201
253
|
return app;
|
|
202
254
|
},
|
|
203
255
|
|
|
204
|
-
invoke(name, input) {
|
|
256
|
+
invoke(name, input, envelope) {
|
|
205
257
|
// Structural mistakes surface synchronously: a stream is not invokable
|
|
206
258
|
// and a missing handler is a bug, and neither should look like a failed
|
|
207
259
|
// call to whatever is awaiting the result.
|
|
@@ -214,7 +266,7 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
214
266
|
if (!operations.has(name)) {
|
|
215
267
|
throw new TypeError(`operation ${JSON.stringify(name)} has no implementation`);
|
|
216
268
|
}
|
|
217
|
-
return runOperation(name, input).catch((cause: unknown) => {
|
|
269
|
+
return runOperation(name, input, envelope).catch((cause: unknown) => {
|
|
218
270
|
// On the bridge, Brobridge reduces an unexpected failure to a fixed
|
|
219
271
|
// sentence on the way out. `invoke` has no transport to do that, and
|
|
220
272
|
// its caller is the AI layer, which may put what it is given into a
|
|
@@ -227,6 +279,8 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
227
279
|
return running.size;
|
|
228
280
|
},
|
|
229
281
|
|
|
282
|
+
gate,
|
|
283
|
+
|
|
230
284
|
abortAll(reason) {
|
|
231
285
|
for (const controller of running) controller.abort(new Error(reason));
|
|
232
286
|
},
|
|
@@ -248,7 +302,15 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
248
302
|
const { group, member } = splitRoute(route);
|
|
249
303
|
if (contract.operations[route] === undefined) continue;
|
|
250
304
|
const service = groups.get(group) ?? {};
|
|
251
|
-
|
|
305
|
+
// The envelope is built here and nowhere else on this path. A tab has
|
|
306
|
+
// no way to describe itself as anything other than the user, because
|
|
307
|
+
// nothing it sends is read when this is filled in.
|
|
308
|
+
service[member] = (raw: unknown): Promise<unknown> =>
|
|
309
|
+
runOperation(route, raw, {
|
|
310
|
+
requestId: crypto.randomUUID(),
|
|
311
|
+
channel: 'user',
|
|
312
|
+
caller: 'tab',
|
|
313
|
+
});
|
|
252
314
|
groups.set(group, service);
|
|
253
315
|
}
|
|
254
316
|
for (const [group, service] of groups) bridge.expose(group, service);
|
|
@@ -257,7 +319,7 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
257
319
|
const spec = contract.streams[route];
|
|
258
320
|
if (spec === undefined) continue;
|
|
259
321
|
bridge.stream(route, (stream: BridgeStream, streamContext: StreamContext) =>
|
|
260
|
-
runStream(stream, streamContext, route, spec, handler, running, logger),
|
|
322
|
+
runStream(stream, streamContext, route, spec, handler, running, logger, gate),
|
|
261
323
|
);
|
|
262
324
|
}
|
|
263
325
|
},
|
|
@@ -276,7 +338,7 @@ export function createReservedHostApp<C extends AnyContract>(
|
|
|
276
338
|
* accidentally undo the reduction by wrapping the message.
|
|
277
339
|
*/
|
|
278
340
|
function wrap(cause: unknown, route: string, logger: HostLogger): unknown {
|
|
279
|
-
if (cause
|
|
341
|
+
if (isPublicError(cause)) return cause.toBridgeError();
|
|
280
342
|
logger.error(`[broapp] ${route} failed: ${String(cause instanceof Error ? cause.stack ?? cause.message : cause)}`);
|
|
281
343
|
return cause;
|
|
282
344
|
}
|
|
@@ -297,10 +359,15 @@ async function runStream<E>(
|
|
|
297
359
|
stream: BridgeStream,
|
|
298
360
|
streamContext: StreamContext,
|
|
299
361
|
route: string,
|
|
300
|
-
spec: {
|
|
362
|
+
spec: {
|
|
363
|
+
params: { parse(value: unknown): unknown };
|
|
364
|
+
event: { parse(value: unknown): unknown };
|
|
365
|
+
effect?: Effect;
|
|
366
|
+
},
|
|
301
367
|
handler: (params: never, sink: StreamSink<E>) => void | Promise<void>,
|
|
302
368
|
running: Set<AbortController>,
|
|
303
369
|
logger: HostLogger,
|
|
370
|
+
gate: Gate,
|
|
304
371
|
): Promise<void> {
|
|
305
372
|
const controller = new AbortController();
|
|
306
373
|
running.add(controller);
|
|
@@ -319,7 +386,7 @@ async function runStream<E>(
|
|
|
319
386
|
running.delete(controller);
|
|
320
387
|
throw new PublicError(
|
|
321
388
|
'invalid_input',
|
|
322
|
-
cause
|
|
389
|
+
isValidationError(cause) ? cause.message : 'invalid stream parameters',
|
|
323
390
|
).toBridgeError();
|
|
324
391
|
}
|
|
325
392
|
|
|
@@ -333,7 +400,23 @@ async function runStream<E>(
|
|
|
333
400
|
};
|
|
334
401
|
|
|
335
402
|
try {
|
|
336
|
-
|
|
403
|
+
// A stream is guarded once, when it starts. Its events are the one call's
|
|
404
|
+
// output, not a series of calls, so there is nothing further to decide
|
|
405
|
+
// once the handler is running.
|
|
406
|
+
await gate.guard(
|
|
407
|
+
{
|
|
408
|
+
requestId: crypto.randomUUID(),
|
|
409
|
+
channel: 'user',
|
|
410
|
+
caller: 'tab',
|
|
411
|
+
signal: controller.signal,
|
|
412
|
+
route,
|
|
413
|
+
effect: effectOf(spec),
|
|
414
|
+
input: params,
|
|
415
|
+
},
|
|
416
|
+
async () => {
|
|
417
|
+
await handler(params as never, sink);
|
|
418
|
+
},
|
|
419
|
+
);
|
|
337
420
|
if (!controller.signal.aborted) await stream.end();
|
|
338
421
|
} catch (cause) {
|
|
339
422
|
// A cancelled stream is not a fault: the browser asked for it, the stream
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The table a waiting question and a later answer meet in.
|
|
3
|
+
*
|
|
4
|
+
* An approver has to ask somebody, and the somebody is at the other end of a
|
|
5
|
+
* route: a chat stream, a launcher tab, an MCP client's own UI. So the ask and
|
|
6
|
+
* the answer are two separate calls, and something has to hold the question in
|
|
7
|
+
* between. That is all this is — with one property that matters more than the
|
|
8
|
+
* bookkeeping.
|
|
9
|
+
*
|
|
10
|
+
* An answer must name the question it is answering. A `requestId` alone would
|
|
11
|
+
* let an answer meant for one call approve a different one that happened to be
|
|
12
|
+
* pending under the same identifier after a rebuild, or after a model changed
|
|
13
|
+
* its arguments between the question and the click. So an answer may carry the
|
|
14
|
+
* release and the arguments hash it believes it is approving, and a value that
|
|
15
|
+
* does not match the pending question is a denial, not a near miss.
|
|
16
|
+
*/
|
|
17
|
+
import type { HostLogger } from './app.ts';
|
|
18
|
+
import type { ApprovalQuestion, Approver } from './gate.ts';
|
|
19
|
+
|
|
20
|
+
/** What the route that received a person's answer passes back in. */
|
|
21
|
+
export interface ApprovalAnswer {
|
|
22
|
+
readonly requestId: string;
|
|
23
|
+
readonly approved: boolean;
|
|
24
|
+
/** When given, must equal the pending question's value or the answer is a mismatch. */
|
|
25
|
+
readonly releaseId?: string;
|
|
26
|
+
readonly argumentsHash?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What happened to an answer. */
|
|
30
|
+
export type AnswerResult = 'accepted' | 'unknown' | 'mismatch';
|
|
31
|
+
|
|
32
|
+
/** An {@link Approver} whose answers arrive later, over some other route. */
|
|
33
|
+
export interface PendingApprovals extends Approver {
|
|
34
|
+
/** Called by the route that receives the person's answer. */
|
|
35
|
+
answer(answer: ApprovalAnswer): AnswerResult;
|
|
36
|
+
/**
|
|
37
|
+
* Questions currently waiting, for a UI to show. `input` is shown as-is;
|
|
38
|
+
* adapters must not put secrets in inputs.
|
|
39
|
+
*/
|
|
40
|
+
readonly pending: readonly ApprovalQuestion[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One question, and the callback that settles it. */
|
|
44
|
+
interface Waiting {
|
|
45
|
+
readonly question: ApprovalQuestion;
|
|
46
|
+
settle(approved: boolean): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build a pending-approval table. */
|
|
50
|
+
export function createPendingApprovals(logger?: HostLogger): PendingApprovals {
|
|
51
|
+
const log: HostLogger = logger ?? console;
|
|
52
|
+
const waiting = new Map<string, Waiting>();
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
ask(question: ApprovalQuestion, signal: AbortSignal): Promise<boolean> {
|
|
56
|
+
// Two questions under one identifier would make an answer ambiguous, and
|
|
57
|
+
// an ambiguous approval is the one thing this table exists to prevent.
|
|
58
|
+
// The caller chose the identifier, so a collision is its bug.
|
|
59
|
+
if (waiting.has(question.requestId)) {
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
`an approval for request ${JSON.stringify(question.requestId)} is already pending`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return new Promise<boolean>((resolve) => {
|
|
65
|
+
let settled = false;
|
|
66
|
+
const finish = (approved: boolean): void => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
waiting.delete(question.requestId);
|
|
70
|
+
signal.removeEventListener('abort', onAbort);
|
|
71
|
+
resolve(approved);
|
|
72
|
+
};
|
|
73
|
+
// A question nobody answers is a denial, not a hung call: the person
|
|
74
|
+
// may have closed the tab, and nothing may run unattended.
|
|
75
|
+
const onAbort = (): void => finish(false);
|
|
76
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
77
|
+
if (signal.aborted) finish(false);
|
|
78
|
+
else waiting.set(question.requestId, { question, settle: finish });
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
answer(answer: ApprovalAnswer): AnswerResult {
|
|
83
|
+
const entry = waiting.get(answer.requestId);
|
|
84
|
+
// Nobody waiting covers both a stale answer and a second answer for a
|
|
85
|
+
// question that has already been settled and removed.
|
|
86
|
+
if (entry === undefined) return 'unknown';
|
|
87
|
+
|
|
88
|
+
const wrong =
|
|
89
|
+
answer.releaseId !== undefined && answer.releaseId !== entry.question.releaseId
|
|
90
|
+
? 'releaseId'
|
|
91
|
+
: answer.argumentsHash !== undefined &&
|
|
92
|
+
answer.argumentsHash !== entry.question.argumentsHash
|
|
93
|
+
? 'argumentsHash'
|
|
94
|
+
: null;
|
|
95
|
+
if (wrong !== null) {
|
|
96
|
+
// An answer about a different release or different arguments is not
|
|
97
|
+
// this question's answer. It is treated as a denial rather than
|
|
98
|
+
// ignored, so the caller stops waiting instead of hanging until the
|
|
99
|
+
// deadline for a question that will never be answered correctly.
|
|
100
|
+
log.warn(
|
|
101
|
+
`[broapp] an approval for ${entry.question.route} named a different ${wrong} and was refused`,
|
|
102
|
+
);
|
|
103
|
+
entry.settle(false);
|
|
104
|
+
return 'mismatch';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
entry.settle(answer.approved);
|
|
108
|
+
return 'accepted';
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
get pending(): readonly ApprovalQuestion[] {
|
|
112
|
+
return [...waiting.values()].map((entry) => entry.question);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|