@frockbot/plugin-shell 0.1.3 → 0.2.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 (41) hide show
  1. package/frockbot.json +4 -0
  2. package/package.json +30 -28
  3. package/src/agent.ts +10 -13
  4. package/src/backend-authoring.test.ts +356 -2
  5. package/src/backend-authoring.ts +585 -20
  6. package/src/backend-composition-input.test.ts +65 -0
  7. package/src/backend-composition-input.ts +58 -0
  8. package/src/backend-composition.ts +23 -5
  9. package/src/backend-configuration.test.ts +549 -1468
  10. package/src/backend-contracts.test.ts +28 -0
  11. package/src/backend-contracts.ts +3 -1
  12. package/src/backend-execution.ts +0 -4
  13. package/src/backend-iframe-ui.test.ts +131 -0
  14. package/src/backend-image.test.ts +8 -8
  15. package/src/backend-image.ts +13 -13
  16. package/src/backend-isolate.test.ts +230 -89
  17. package/src/backend-isolate.ts +103 -200
  18. package/src/backend-package-catalog.test.ts +451 -0
  19. package/src/backend-package-catalog.ts +923 -0
  20. package/src/backend-recovery-integration.test.ts +17 -67
  21. package/src/backend-routines.ts +1 -1
  22. package/src/backend-runner-iframe.test.ts +74 -0
  23. package/src/backend-runner.ts +192 -0
  24. package/src/backend.ts +1198 -1781
  25. package/src/client/FrockBotApp.vue +44 -73
  26. package/src/client/PackageIframeHost.vue +218 -0
  27. package/src/client/PackageIframeSettings.vue +52 -0
  28. package/src/client/SendPayloadView.vue +0 -60
  29. package/src/client/index.test.ts +439 -380
  30. package/src/client/index.ts +163 -257
  31. package/src/client/model-presentation.test.ts +21 -8
  32. package/src/client/model-presentation.ts +14 -7
  33. package/src/client/package-iframe-host-message.test.ts +41 -0
  34. package/src/client/package-iframe-host-message.ts +27 -0
  35. package/src/client/styles.css +0 -27
  36. package/src/composition-views.ts +54 -0
  37. package/src/settings-links.test.ts +2 -10
  38. package/src/settings-links.ts +1 -13
  39. package/src/shared.ts +10 -13
  40. package/src/backend-assignment.test.ts +0 -161
  41. package/src/backend-assignment.ts +0 -274
@@ -1,116 +1,61 @@
1
1
  // The Bot Durable Object half of the isolate capability boundary.
2
2
  //
3
- // A loaded Bot Package sees exactly two bindings: `IDENTITY` (a plain object)
4
- // and `CAPABILITIES` (a loopback service binding). Every method behind
5
- // `CAPABILITIES` ends up here, in the authority that owns the Bot's durable
6
- // state so an authority-widening request becomes a durable pending decision
7
- // rather than a grant, and a model call records its normalized request and
8
- // acquires its credential lease through the existing provider path before a
9
- // single byte leaves the account.
3
+ // Every Package mounted for one Bot receives the same authority projection:
4
+ // the User's ready Connections, the Bot's configured model, and the shared
5
+ // tool, Memory, Workspace, and notification surfaces. Package identity remains
6
+ // in props only for attribution; it never narrows the authority list.
10
7
  import type {
11
- IsolateAuthorityRequestV1,
12
- IsolateCapabilityDescriptorV1,
8
+ IsolateCapabilityListV1,
9
+ IsolateConnectionLeaseV1,
10
+ IsolateConnectionOutcomeV1,
11
+ IsolateConnectionV1,
12
+ IsolateModelBindingV1,
13
13
  IsolateModelInvocationV1,
14
- IsolatePendingDecisionV1,
15
14
  LlmStreamEvent,
16
15
  NormalizedModelRequest,
17
16
  } from "@frockbot/kernel-contracts";
18
- import {
19
- decodeIsolateAuthorityRequestV1,
20
- encodeIsolateModelEventLineV1,
21
- } from "@frockbot/kernel-contracts";
17
+ import { encodeIsolateModelEventLineV1 } from "@frockbot/kernel-contracts";
22
18
  import type { BotIsolateArtifactStore } from "@frockbot/kernel-composition/isolate";
23
19
 
24
- /**
25
- * The compatibility date every Bot isolate is loaded with. Pinned beside the
26
- * gateway Worker's own so Bot-authored code cannot outrun the kernel wrapper.
27
- */
20
+ export type { IsolateModelBindingV1 } from "@frockbot/kernel-contracts";
21
+
28
22
  export const BOT_ISOLATE_COMPATIBILITY_DATE = "2026-08-27";
29
23
 
30
- /**
31
- * What travels in `ctx.exports.BotCapabilities({ props })`. Structured-clonable
32
- * by necessity: props cross into a loopback service binding.
33
- */
34
24
  export interface BotCapabilitiesPropsV1 {
35
25
  userId: string;
36
26
  botId: string;
27
+ runId: string;
28
+ sessionId: string;
29
+ turnId: string;
37
30
  generationId: string;
38
31
  packageId: string;
39
- /** Already resolved and filtered to enabled Assignments by the authority. */
40
- assignments: IsolateAssignmentV1[];
32
+ connections: IsolateConnectionV1[];
33
+ model?: IsolateModelBindingV1;
34
+ memory: boolean;
35
+ workspace: boolean;
41
36
  }
42
37
 
43
- export const ISOLATE_DECISION_PREFIX = "isolate:decision:";
44
38
  export const ISOLATE_MODEL_REQUEST_PREFIX = "isolate:model-request:";
45
39
 
46
- /** A durable record that the Bot asked for authority it does not hold. */
47
- export interface IsolatePendingAuthorityDecisionV1 {
48
- schemaVersion: 1;
49
- decisionId: string;
50
- botId: string;
51
- packageId: string;
52
- generationId: string;
53
- capabilityId: string;
54
- reason: string;
55
- requestedAt: string;
56
- status: "pending";
57
- }
58
-
59
- /** The intent recorded before a Bot-authored adapter's model call is forwarded. */
60
40
  export interface IsolateModelRequestRecordV1 {
61
41
  schemaVersion: 1;
62
- /** Minted by this Durable Object; the record is keyed by it. */
63
42
  recordId: string;
64
- /** The Bot's own correlation id. Bounded, and never a storage key. */
65
43
  requestId: string;
66
44
  botId: string;
67
45
  packageId: string;
68
46
  generationId: string;
69
- capabilityId: string;
70
47
  request: NormalizedModelRequest;
71
48
  recordedAt: string;
72
49
  }
73
50
 
74
- /** The narrow storage surface this module needs from the Durable Object. */
75
51
  export interface IsolateCapabilityStore {
76
52
  put(key: string, value: unknown): Promise<void>;
77
- get<T>(key: string): Promise<T | undefined>;
78
53
  list<T>(options: { prefix: string }): Promise<Map<string, T>>;
79
54
  }
80
55
 
81
- /** One enabled Assignment, already resolved the way `plugin-shell` resolves them. */
82
- export interface IsolateAssignmentV1 {
83
- assignmentId: string;
84
- packageId: string;
85
- capabilityId: string;
86
- kind: IsolateCapabilityDescriptorV1["kind"];
87
- connectionId?: string;
88
- providerModelId?: string;
89
- }
90
-
91
- /**
92
- * The Bot's durable model binding, resolved by the authority from the Bot's
93
- * own configuration and the User's Connection — never from anything the Bot
94
- * supplied. An `invokeModel` request is authorized only when it names exactly
95
- * this provider and this model, and it is forwarded carrying exactly this
96
- * binding.
97
- */
98
- export interface IsolateModelBindingV1 {
99
- assignmentId: string;
100
- packageId: string;
101
- capabilityId: string;
102
- connectionId: string;
103
- provider: string;
104
- providerModelId: string;
105
- connectionGeneration?: string;
106
- catalogGeneration?: string;
107
- }
108
-
109
- /** The bound Bot-supplied correlation id: a field, never a key, and never unbounded. */
110
56
  export const MAX_ISOLATE_REQUEST_ID = 256;
111
57
 
112
58
  export interface IsolateModelPath {
113
- /** Streams through the mounted provider Plugin; the lease is taken inside it. */
114
59
  stream(
115
60
  request: NormalizedModelRequest,
116
61
  signal: AbortSignal,
@@ -122,57 +67,56 @@ export interface IsolateCapabilityHostOptions {
122
67
  botId: string;
123
68
  packageId: string;
124
69
  generationId: string;
125
- /** Assignment-derived and nothing else. */
126
- assignments: readonly IsolateAssignmentV1[];
127
- /**
128
- * The one model binding this Bot durably holds, or absent when it holds
129
- * none. Absent means every model request is a pending decision.
130
- */
70
+ connections: readonly IsolateConnectionV1[];
131
71
  modelBinding?: IsolateModelBindingV1;
132
- /** Absent when the Bot has no enabled model Assignment at all. */
133
72
  modelPath?: IsolateModelPath;
73
+ memory: boolean;
74
+ workspace: boolean;
134
75
  now?(): Date;
135
76
  newId?(): string;
136
77
  }
137
78
 
138
79
  export interface IsolateCapabilityHost {
139
- list(): Promise<IsolateCapabilityDescriptorV1[]>;
140
- requestAuthority(request: unknown): Promise<IsolatePendingDecisionV1>;
80
+ list(): Promise<IsolateCapabilityListV1>;
141
81
  invokeModel(
142
82
  request: NormalizedModelRequest,
143
83
  ): Promise<IsolateModelInvocationV1>;
144
- pendingDecisions(): Promise<IsolatePendingAuthorityDecisionV1[]>;
145
84
  recordedModelRequests(): Promise<IsolateModelRequestRecordV1[]>;
146
85
  }
147
86
 
148
- /**
149
- * The enabled model Assignment that can serve this request, if any.
150
- *
151
- * An Assignment authorizes exactly one Package, one Connection, and one
152
- * provider model: the Bot's durable binding. A request naming any other
153
- * provider or model resolves to nothing, whatever the Bot claims about it —
154
- * the Bot-supplied `modelBinding` is never read here or anywhere downstream.
155
- */
156
- export function matchingModelAssignmentV1(
157
- assignments: readonly IsolateAssignmentV1[],
87
+ export function matchingModelBindingV1(
158
88
  binding: IsolateModelBindingV1 | undefined,
159
89
  request: NormalizedModelRequest,
160
- ): IsolateAssignmentV1 | undefined {
161
- if (!binding) return undefined;
90
+ ): IsolateModelBindingV1 | undefined {
91
+ const admitted = request.modelBinding;
162
92
  if (
93
+ !binding ||
94
+ !admitted ||
163
95
  request.provider !== binding.provider ||
164
- request.model !== binding.providerModelId
96
+ request.model !== binding.providerModelId ||
97
+ admitted.connectionId !== binding.connectionId ||
98
+ admitted.connectionGeneration !== binding.connectionGeneration ||
99
+ admitted.catalogGeneration !== binding.catalogGeneration
165
100
  ) {
166
101
  return undefined;
167
102
  }
168
- return assignments.find(
169
- (assignment) =>
170
- assignment.kind === "model" &&
171
- assignment.assignmentId === binding.assignmentId &&
172
- assignment.packageId === binding.packageId &&
173
- assignment.capabilityId === binding.capabilityId &&
174
- assignment.connectionId === binding.connectionId &&
175
- assignment.providerModelId === binding.providerModelId,
103
+ return binding;
104
+ }
105
+
106
+ /**
107
+ * A loaded isolate may receive only the Connection generation baked into its
108
+ * admitted authority snapshot. A later User Connection change gets a new
109
+ * binding digest and isolate identity; it must not leak through this old stub.
110
+ */
111
+ export function matchesAdmittedConnectionV1(
112
+ admitted: IsolateConnectionV1 | undefined,
113
+ outcome: IsolateConnectionOutcomeV1,
114
+ ): outcome is IsolateConnectionLeaseV1 {
115
+ return (
116
+ admitted !== undefined &&
117
+ outcome.status === "available" &&
118
+ outcome.connectionId === admitted.connectionId &&
119
+ outcome.generation === admitted.generation
176
120
  );
177
121
  }
178
122
 
@@ -182,47 +126,20 @@ export function createIsolateCapabilityHost(
182
126
  const now = options.now ?? (() => new Date());
183
127
  const newId = options.newId ?? (() => crypto.randomUUID());
184
128
 
185
- async function recordDecision(
186
- capabilityId: string,
187
- reason: string,
188
- ): Promise<IsolatePendingDecisionV1> {
189
- const decisionId = `decision-${newId()}`;
190
- const record: IsolatePendingAuthorityDecisionV1 = {
191
- schemaVersion: 1,
192
- decisionId,
193
- botId: options.botId,
194
- packageId: options.packageId,
195
- generationId: options.generationId,
196
- capabilityId,
197
- reason,
198
- requestedAt: now().toISOString(),
199
- status: "pending",
200
- };
201
- await options.storage.put(
202
- `${ISOLATE_DECISION_PREFIX}${decisionId}`,
203
- record,
204
- );
205
- return { status: "pending-user-decision", decisionId };
206
- }
207
-
208
129
  return {
209
- list(): Promise<IsolateCapabilityDescriptorV1[]> {
210
- return Promise.resolve(
211
- options.assignments.map((assignment) => ({
212
- capabilityId: assignment.capabilityId,
213
- kind: assignment.kind,
214
- })),
215
- );
216
- },
217
-
218
- async requestAuthority(
219
- request: unknown,
220
- ): Promise<IsolatePendingDecisionV1> {
221
- const decoded: IsolateAuthorityRequestV1 =
222
- decodeIsolateAuthorityRequestV1(request);
223
- // Self-modification never widens authority, even when the capability is
224
- // already assigned: the answer is a decision the User makes.
225
- return await recordDecision(decoded.capabilityId, decoded.reason);
130
+ list(): Promise<IsolateCapabilityListV1> {
131
+ return Promise.resolve({
132
+ status: "available",
133
+ connections: structuredClone([...options.connections]),
134
+ ...(options.modelBinding
135
+ ? { model: structuredClone(options.modelBinding) }
136
+ : {}),
137
+ tools: true,
138
+ memory: options.memory,
139
+ workspace: options.workspace,
140
+ notify: true,
141
+ schedule: true,
142
+ });
226
143
  },
227
144
 
228
145
  async invokeModel(
@@ -231,36 +148,25 @@ export function createIsolateCapabilityHost(
231
148
  if (request.requestId.length > MAX_ISOLATE_REQUEST_ID) {
232
149
  throw new Error("isolate model request requestId is not bounded");
233
150
  }
234
- const binding = options.modelBinding;
235
- const assignment = matchingModelAssignmentV1(
236
- options.assignments,
237
- binding,
238
- request,
239
- );
240
- if (!assignment || !binding || !options.modelPath) {
241
- return await recordDecision(
242
- `models:${request.provider}:${request.model}`,
243
- `Bot Package "${options.packageId}" asked to invoke a model with no matching enabled Assignment`,
244
- );
151
+ const binding = matchingModelBindingV1(options.modelBinding, request);
152
+ if (!binding || !options.modelPath) {
153
+ return {
154
+ status: "unavailable",
155
+ reason: options.modelBinding
156
+ ? "the request does not match this Bot's configured model"
157
+ : "this Bot has no configured model",
158
+ };
245
159
  }
246
- // The binding the provider path receives is the authority's, never the
247
- // Bot's: a Bot-composed request carries no Connection authority.
248
160
  const forwarded: NormalizedModelRequest = {
249
161
  ...structuredClone(request),
250
162
  modelBinding: {
251
163
  connectionId: binding.connectionId,
252
- ...(binding.connectionGeneration
253
- ? { connectionGeneration: binding.connectionGeneration }
254
- : {}),
164
+ connectionGeneration: binding.connectionGeneration,
255
165
  ...(binding.catalogGeneration
256
166
  ? { catalogGeneration: binding.catalogGeneration }
257
167
  : {}),
258
168
  },
259
169
  };
260
- // Record the exact normalized request before forwarding; the provider
261
- // path takes the credential lease on the way through. The record is
262
- // keyed by an id this authority mints, so a Bot cannot overwrite one of
263
- // its own earlier records by reusing a `requestId`.
264
170
  const recordId = `model-request-${newId()}`;
265
171
  const record: IsolateModelRequestRecordV1 = {
266
172
  schemaVersion: 1,
@@ -269,7 +175,6 @@ export function createIsolateCapabilityHost(
269
175
  botId: options.botId,
270
176
  packageId: options.packageId,
271
177
  generationId: options.generationId,
272
- capabilityId: assignment.capabilityId,
273
178
  request: forwarded,
274
179
  recordedAt: now().toISOString(),
275
180
  };
@@ -278,22 +183,16 @@ export function createIsolateCapabilityHost(
278
183
  record,
279
184
  );
280
185
  const controller = new AbortController();
281
- const events = options.modelPath.stream(forwarded, controller.signal);
282
186
  return {
283
187
  status: "streaming",
284
188
  requestId: request.requestId,
285
- events: isolateModelEventStreamV1(events, controller),
189
+ events: isolateModelEventStreamV1(
190
+ options.modelPath.stream(forwarded, controller.signal),
191
+ controller,
192
+ ),
286
193
  };
287
194
  },
288
195
 
289
- async pendingDecisions(): Promise<IsolatePendingAuthorityDecisionV1[]> {
290
- const stored =
291
- await options.storage.list<IsolatePendingAuthorityDecisionV1>({
292
- prefix: ISOLATE_DECISION_PREFIX,
293
- });
294
- return [...stored.values()];
295
- },
296
-
297
196
  async recordedModelRequests(): Promise<IsolateModelRequestRecordV1[]> {
298
197
  const stored = await options.storage.list<IsolateModelRequestRecordV1>({
299
198
  prefix: ISOLATE_MODEL_REQUEST_PREFIX,
@@ -346,31 +245,35 @@ export function isolateModelEventStreamV1(
346
245
  }
347
246
 
348
247
  /**
349
- * The content address of the bindings an isolate is loaded with: its
350
- * Assignments and the Composition generation whose `CAPABILITIES` stub is
351
- * baked into its `env`. A loader id is served from cache, so a Bot whose
352
- * Assignments change must get a different isolate rather than one that keeps a
353
- * revoked binding and a new generation must get a different isolate rather
354
- * than one whose `env` still names the generation it was first loaded under.
355
- * Both are bindings the isolate was granted, so both belong in this digest and
356
- * the loader id stays derived from the artifact set and the binding digest
357
- * alone.
248
+ * The content address of the bindings baked into a loaded isolate's env.
249
+ *
250
+ * Connection order is irrelevant; ids and generations are the authority
251
+ * identity. User, Bot, the resolved model binding, and the pinned Composition
252
+ * generation complete the digest. Package id is deliberately absent: two
253
+ * Packages of one Bot receive the same authority projection.
358
254
  */
359
- export async function isolateBindingDigestV1(
360
- assignments: readonly IsolateAssignmentV1[],
361
- generationId: string,
362
- ): Promise<string> {
363
- const ordered = [...assignments]
364
- .map((assignment) => ({
365
- assignmentId: assignment.assignmentId,
366
- packageId: assignment.packageId,
367
- capabilityId: assignment.capabilityId,
368
- kind: assignment.kind,
369
- connectionId: assignment.connectionId ?? null,
370
- providerModelId: assignment.providerModelId ?? null,
371
- }))
372
- .sort((left, right) => left.assignmentId.localeCompare(right.assignmentId));
373
- return await sha256Hex(JSON.stringify({ generationId, ordered }));
255
+ export async function isolateBindingDigestV1(input: {
256
+ userId: string;
257
+ botId: string;
258
+ connections: readonly Pick<
259
+ IsolateConnectionV1,
260
+ "connectionId" | "generation"
261
+ >[];
262
+ model?: IsolateModelBindingV1;
263
+ compositionGenerationId: string;
264
+ }): Promise<string> {
265
+ const connections = [...input.connections]
266
+ .map(({ connectionId, generation }) => ({ connectionId, generation }))
267
+ .sort((left, right) => left.connectionId.localeCompare(right.connectionId));
268
+ return await sha256Hex(
269
+ JSON.stringify({
270
+ userId: input.userId,
271
+ botId: input.botId,
272
+ compositionGenerationId: input.compositionGenerationId,
273
+ connections,
274
+ model: input.model ?? null,
275
+ }),
276
+ );
374
277
  }
375
278
 
376
279
  async function sha256Hex(value: string): Promise<string> {