broapp 0.1.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.
Files changed (39) hide show
  1. package/README.md +13 -4
  2. package/package.json +10 -2
  3. package/src/ai/host/adapter.ts +109 -0
  4. package/src/ai/host/create-ai.ts +266 -0
  5. package/src/ai/host/fake.ts +230 -0
  6. package/src/ai/host/from-contract.ts +105 -0
  7. package/src/ai/host/index.ts +37 -0
  8. package/src/ai/host/registry.ts +232 -0
  9. package/src/ai/host/run-types.ts +14 -0
  10. package/src/ai/host/run.ts +540 -0
  11. package/src/ai/host/secrets.ts +118 -0
  12. package/src/ai/host/settings.ts +83 -0
  13. package/src/ai/host/threads.ts +366 -0
  14. package/src/ai/host/tool.ts +95 -0
  15. package/src/ai/react/AiChat.tsx +242 -0
  16. package/src/ai/react/AiSettings.tsx +228 -0
  17. package/src/ai/react/ai.css +166 -0
  18. package/src/ai/react/index.tsx +38 -0
  19. package/src/ai/react/provider.tsx +97 -0
  20. package/src/ai/react/use-ai-chat.ts +317 -0
  21. package/src/ai/react/use-ai-models.ts +70 -0
  22. package/src/ai/react/use-ai-settings.ts +105 -0
  23. package/src/ai/shared/contract.ts +247 -0
  24. package/src/ai/shared/index.ts +19 -0
  25. package/src/ai/shared/types.check.ts +71 -0
  26. package/src/ai/shared/types.ts +147 -0
  27. package/src/host/app.ts +183 -36
  28. package/src/host/approvals.ts +115 -0
  29. package/src/host/gate.ts +380 -0
  30. package/src/host/index.ts +25 -0
  31. package/src/host/paths.ts +6 -2
  32. package/src/host/runtime.ts +23 -2
  33. package/src/react/hooks.tsx +37 -3
  34. package/src/react/index.ts +1 -0
  35. package/src/shared/contract.ts +99 -2
  36. package/src/shared/countdown.ts +36 -0
  37. package/src/shared/errors.ts +66 -2
  38. package/src/shared/index.ts +14 -3
  39. package/src/shared/schema.ts +141 -28
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 { splitRoute } from '../shared/contract.ts';
27
- import { PublicError } from '../shared/errors.ts';
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 { ValidationError } from '../shared/schema.ts';
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. */
@@ -97,18 +120,66 @@ export interface HostApp<C extends AnyContract> {
97
120
  * a developer is present to see it.
98
121
  */
99
122
  mount(bridge: Bridge): void;
123
+ /**
124
+ * Run one operation directly, without the bridge. Input is validated and
125
+ * output is checked exactly as for a call from the browser, and the same
126
+ * error boundary applies. This is how the AI layer lets a model call an
127
+ * application's operations as tools.
128
+ *
129
+ * A route that is a stream, or one no handler implements, is a programming
130
+ * error rather than a call failure, so it throws `TypeError` at the call
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.
136
+ */
137
+ invoke<K extends OperationName<C>>(
138
+ name: K,
139
+ input: unknown,
140
+ envelope: Envelope,
141
+ ): Promise<OperationOutput<C, K>>;
100
142
  /** Abort every stream this app currently has open. Called during shutdown. */
101
143
  abortAll(reason: string): void;
102
144
  /** How many streams are running right now. */
103
145
  readonly activeStreams: number;
146
+ /** The gate this application's calls pass through. */
147
+ readonly gate: Gate;
104
148
  }
105
149
 
106
- /** Build the host side of a contract. */
150
+ /**
151
+ * Build the host side of an application's contract.
152
+ *
153
+ * The reserved-group check lives here rather than in `defineContract` because
154
+ * Broapp's own AI contract is built with `defineContract` and legitimately
155
+ * uses the group. An application that declares `ai.*` would collide with the
156
+ * AI layer on the same bridge, so it is refused while a developer is watching.
157
+ */
107
158
  export function createHostApp<C extends AnyContract>(
108
159
  contract: C,
109
160
  options: HostAppOptions = {},
161
+ ): HostApp<C> {
162
+ assertNoReservedRoutes(contract);
163
+ return createReservedHostApp(contract, options);
164
+ }
165
+
166
+ /**
167
+ * Build a host app without the reserved-group check.
168
+ *
169
+ * Not for applications. `broapp/ai/host` uses it to mount the AI contract,
170
+ * which is the one contract allowed to own the `ai` group.
171
+ */
172
+ export function createReservedHostApp<C extends AnyContract>(
173
+ contract: C,
174
+ options: HostAppOptions = {},
110
175
  ): HostApp<C> {
111
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 });
112
183
  const operations = new Map<string, OperationHandler<C, never>>();
113
184
  const streams = new Map<string, StreamHandlerFor<C, never>>();
114
185
  const running = new Set<AbortController>();
@@ -120,6 +191,53 @@ export function createHostApp<C extends AnyContract>(
120
191
  }
121
192
  }
122
193
 
194
+ /**
195
+ * One operation call, from the bridge or from {@link HostApp.invoke}.
196
+ *
197
+ * Both paths must validate the same way and fail the same way — an AI tool
198
+ * call is not more trusted than a browser call just because it originates
199
+ * inside the host — so there is one implementation and two callers.
200
+ */
201
+ async function runOperation(route: string, raw: unknown, envelope: Envelope): Promise<unknown> {
202
+ const spec = contract.operations[route];
203
+ const handler = operations.get(route);
204
+ if (spec === undefined || handler === undefined) {
205
+ throw new TypeError(`operation ${JSON.stringify(route)} has no implementation`);
206
+ }
207
+ let input: unknown;
208
+ try {
209
+ input = spec.input.parse(raw);
210
+ } catch (cause) {
211
+ // A validation message names a field and a constraint from the contract
212
+ // the browser already has. It carries nothing the caller did not send,
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.
218
+ throw new PublicError(
219
+ 'invalid_input',
220
+ isValidationError(cause) ? cause.message : 'invalid input',
221
+ ).toBridgeError();
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
+ };
230
+ try {
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
+ );
235
+ return spec.output.parse(output);
236
+ } catch (cause) {
237
+ throw wrap(cause, route, logger);
238
+ }
239
+ }
240
+
123
241
  const app: HostApp<C> = {
124
242
  operation(name, handler) {
125
243
  known('operation', name);
@@ -135,10 +253,34 @@ export function createHostApp<C extends AnyContract>(
135
253
  return app;
136
254
  },
137
255
 
256
+ invoke(name, input, envelope) {
257
+ // Structural mistakes surface synchronously: a stream is not invokable
258
+ // and a missing handler is a bug, and neither should look like a failed
259
+ // call to whatever is awaiting the result.
260
+ if (Object.prototype.hasOwnProperty.call(contract.streams, name)) {
261
+ throw new TypeError(`route ${JSON.stringify(name)} is a stream, which cannot be invoked`);
262
+ }
263
+ if (!Object.prototype.hasOwnProperty.call(contract.operations, name)) {
264
+ throw new TypeError(`operation ${JSON.stringify(name)} is not declared in the contract`);
265
+ }
266
+ if (!operations.has(name)) {
267
+ throw new TypeError(`operation ${JSON.stringify(name)} has no implementation`);
268
+ }
269
+ return runOperation(name, input, envelope).catch((cause: unknown) => {
270
+ // On the bridge, Brobridge reduces an unexpected failure to a fixed
271
+ // sentence on the way out. `invoke` has no transport to do that, and
272
+ // its caller is the AI layer, which may put what it is given into a
273
+ // transcript — so the same reduction is applied here.
274
+ throw isPublicBridgeError(cause) ? cause : new Error(INTERNAL_ERROR_MESSAGE);
275
+ }) as Promise<never>;
276
+ },
277
+
138
278
  get activeStreams() {
139
279
  return running.size;
140
280
  },
141
281
 
282
+ gate,
283
+
142
284
  abortAll(reason) {
143
285
  for (const controller of running) controller.abort(new Error(reason));
144
286
  },
@@ -156,35 +298,19 @@ export function createHostApp<C extends AnyContract>(
156
298
  // dotted routes are collected back into groups here. This is the only
157
299
  // place the two namings meet.
158
300
  const groups = new Map<string, Record<string, unknown>>();
159
- for (const [route, handler] of operations) {
301
+ for (const route of operations.keys()) {
160
302
  const { group, member } = splitRoute(route);
161
- const spec = contract.operations[route];
162
- if (spec === undefined) continue;
303
+ if (contract.operations[route] === undefined) continue;
163
304
  const service = groups.get(group) ?? {};
164
- service[member] = async (raw: unknown): Promise<unknown> => {
165
- const context: CallContext = { route };
166
- let input: unknown;
167
- try {
168
- input = spec.input.parse(raw);
169
- } catch (cause) {
170
- // A validation message names a field and a constraint from the
171
- // contract the browser already has. It carries nothing the caller
172
- // did not send, so it is safe to return and useful to see.
173
- throw new PublicError(
174
- 'invalid_input',
175
- cause instanceof ValidationError ? cause.message : 'invalid input',
176
- ).toBridgeError();
177
- }
178
- try {
179
- const output = await (handler as OperationHandler<C, never>)(
180
- input as never,
181
- context,
182
- );
183
- return spec.output.parse(output);
184
- } catch (cause) {
185
- throw wrap(cause, route, logger);
186
- }
187
- };
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
+ });
188
314
  groups.set(group, service);
189
315
  }
190
316
  for (const [group, service] of groups) bridge.expose(group, service);
@@ -193,7 +319,7 @@ export function createHostApp<C extends AnyContract>(
193
319
  const spec = contract.streams[route];
194
320
  if (spec === undefined) continue;
195
321
  bridge.stream(route, (stream: BridgeStream, streamContext: StreamContext) =>
196
- runStream(stream, streamContext, route, spec, handler, running, logger),
322
+ runStream(stream, streamContext, route, spec, handler, running, logger, gate),
197
323
  );
198
324
  }
199
325
  },
@@ -212,7 +338,7 @@ export function createHostApp<C extends AnyContract>(
212
338
  * accidentally undo the reduction by wrapping the message.
213
339
  */
214
340
  function wrap(cause: unknown, route: string, logger: HostLogger): unknown {
215
- if (cause instanceof PublicError) return cause.toBridgeError();
341
+ if (isPublicError(cause)) return cause.toBridgeError();
216
342
  logger.error(`[broapp] ${route} failed: ${String(cause instanceof Error ? cause.stack ?? cause.message : cause)}`);
217
343
  return cause;
218
344
  }
@@ -233,10 +359,15 @@ async function runStream<E>(
233
359
  stream: BridgeStream,
234
360
  streamContext: StreamContext,
235
361
  route: string,
236
- spec: { params: { parse(value: unknown): unknown }; event: { parse(value: unknown): unknown } },
362
+ spec: {
363
+ params: { parse(value: unknown): unknown };
364
+ event: { parse(value: unknown): unknown };
365
+ effect?: Effect;
366
+ },
237
367
  handler: (params: never, sink: StreamSink<E>) => void | Promise<void>,
238
368
  running: Set<AbortController>,
239
369
  logger: HostLogger,
370
+ gate: Gate,
240
371
  ): Promise<void> {
241
372
  const controller = new AbortController();
242
373
  running.add(controller);
@@ -255,7 +386,7 @@ async function runStream<E>(
255
386
  running.delete(controller);
256
387
  throw new PublicError(
257
388
  'invalid_input',
258
- cause instanceof ValidationError ? cause.message : 'invalid stream parameters',
389
+ isValidationError(cause) ? cause.message : 'invalid stream parameters',
259
390
  ).toBridgeError();
260
391
  }
261
392
 
@@ -269,7 +400,23 @@ async function runStream<E>(
269
400
  };
270
401
 
271
402
  try {
272
- await handler(params as never, sink);
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
+ );
273
420
  if (!controller.signal.aborted) await stream.end();
274
421
  } catch (cause) {
275
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
+ }