@frockbot/plugin-shell 0.1.2 → 0.1.4

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.
@@ -6,7 +6,6 @@ import type {
6
6
  import type {
7
7
  BotExecutionPlanV1,
8
8
  BotSettingsViewV1,
9
- ConnectionView,
10
9
  } from "@frockbot/configuration-core";
11
10
  import type { BotTurnCommand, BotTurnCompletion } from "./backend-contracts.js";
12
11
 
@@ -17,9 +16,6 @@ export interface BotResidentProjection {
17
16
  settings: BotSettingsViewV1;
18
17
  executionPlan: BotExecutionPlanV1;
19
18
  systemPromptSection: string;
20
- authorizeConnection(
21
- assignment: BotSettingsViewV1["assignments"][number],
22
- ): Promise<ConnectionView>;
23
19
  }
24
20
 
25
21
  export interface BotResidentTurnExecution {
@@ -5,8 +5,8 @@ import {
5
5
  } from "@frockbot/plugin-image/testing";
6
6
  import {
7
7
  createBotImageHost,
8
- createWorkersAiImageModelV1,
9
- decodeWorkersAiImageV1,
8
+ createNativeAiImageModelV1,
9
+ decodeNativeAiImageV1,
10
10
  } from "./backend-image.ts";
11
11
 
12
12
  const IDENTITY = { userId: "user-1", botId: "bot-1" };
@@ -56,7 +56,7 @@ describe("normalizing what Workers AI answered", () => {
56
56
  const png = fakePngBytesV1(64, 32);
57
57
 
58
58
  test("accepts the base64 envelope the FLUX models answer", async () => {
59
- const buffer = await decodeWorkersAiImageV1({ image: base64(png) });
59
+ const buffer = await decodeNativeAiImageV1({ image: base64(png) });
60
60
  expect([...new Uint8Array(buffer)]).toEqual([...png]);
61
61
  });
62
62
 
@@ -68,23 +68,23 @@ describe("normalizing what Workers AI answered", () => {
68
68
  controller.close();
69
69
  },
70
70
  });
71
- expect([...new Uint8Array(await decodeWorkersAiImageV1(stream))]).toEqual([
71
+ expect([...new Uint8Array(await decodeNativeAiImageV1(stream))]).toEqual([
72
72
  ...png,
73
73
  ]);
74
74
  });
75
75
 
76
76
  test("accepts a raw buffer or view", async () => {
77
77
  expect([
78
- ...new Uint8Array(await decodeWorkersAiImageV1(png.slice().buffer)),
78
+ ...new Uint8Array(await decodeNativeAiImageV1(png.slice().buffer)),
79
79
  ]).toEqual([...png]);
80
- expect([...new Uint8Array(await decodeWorkersAiImageV1(png))]).toEqual([
80
+ expect([...new Uint8Array(await decodeNativeAiImageV1(png))]).toEqual([
81
81
  ...png,
82
82
  ]);
83
83
  });
84
84
 
85
85
  test("refuses anything else rather than storing it", async () => {
86
86
  for (const answer of [undefined, null, 7, "hello", { image: 3 }, {}]) {
87
- await expect(decodeWorkersAiImageV1(answer)).rejects.toThrow(
87
+ await expect(decodeNativeAiImageV1(answer)).rejects.toThrow(
88
88
  "not an image",
89
89
  );
90
90
  }
@@ -92,7 +92,7 @@ describe("normalizing what Workers AI answered", () => {
92
92
 
93
93
  test("passes the requested size through to the binding", async () => {
94
94
  const calls: Array<[string, Record<string, unknown>]> = [];
95
- const model = createWorkersAiImageModelV1({
95
+ const model = createNativeAiImageModelV1({
96
96
  run: (name, input) => {
97
97
  calls.push([name, input]);
98
98
  return Promise.resolve({ image: base64(png) });
@@ -1,15 +1,15 @@
1
1
  // The Bot Durable Object's half of the image-generation seam.
2
2
  //
3
3
  // The Image Package consumes a narrow `ImageModelV1` — one method, answering
4
- // raw image bytes — and knows nothing about Workers AI. This module is the
4
+ // raw image bytes — and knows nothing about Cloudflare's native AI binding. This module is the
5
5
  // adapter: it takes the `AI` binding off the Durable Object's environment,
6
- // normalizes the three shapes a Workers AI text-to-image model answers in, and
6
+ // normalizes the three shapes a native text-to-image model answers in, and
7
7
  // hands the Package a seam with no platform vocabulary in it. "Electron,
8
8
  // Cloudflare, provider SDK, and Computer implementation types remain inside
9
9
  // their adapters."
10
10
  //
11
11
  // The binding is optional, exactly as `PACKAGE_BUNDLER` is. A deployment with
12
- // no Workers AI binding still mounts the Package, and `generate_image` then
12
+ // no native AI binding still mounts the Package, and `generate_image` then
13
13
  // refuses visibly on the Turn that calls it — which is a better answer than a
14
14
  // tool that silently vanishes from the catalog, and a far better one than a
15
15
  // `TypeError` inside the Agent loop.
@@ -38,11 +38,11 @@ export interface BotImageTurn {
38
38
  }
39
39
 
40
40
  /**
41
- * The Workers AI binding, declared structurally so this module names no
41
+ * The native AI binding, declared structurally so this module names no
42
42
  * Cloudflare type it does not have to. `run` is the whole of what an image
43
43
  * model needs from it.
44
44
  */
45
- export interface WorkersAiBindingV1 {
45
+ export interface NativeAiBindingV1 {
46
46
  run(model: string, input: Record<string, unknown>): Promise<unknown>;
47
47
  }
48
48
 
@@ -51,7 +51,7 @@ export interface WorkersAiBindingV1 {
51
51
  * as its own type so the binding's absence is a typed state, not a cast.
52
52
  */
53
53
  export interface BotImageEnv {
54
- AI?: WorkersAiBindingV1;
54
+ AI?: NativeAiBindingV1;
55
55
  WORKSPACE_FILES?: WorkspaceFilesV1;
56
56
  }
57
57
 
@@ -88,7 +88,7 @@ async function readStream(
88
88
  }
89
89
 
90
90
  /**
91
- * The bytes inside whatever a Workers AI text-to-image model answered.
91
+ * The bytes inside whatever a native text-to-image model answered.
92
92
  *
93
93
  * The catalog does not agree with itself: `flux-1-schnell` and the FLUX.2
94
94
  * models answer `{ image: "<base64>" }`, the Stable Diffusion models answer a
@@ -96,7 +96,7 @@ async function readStream(
96
96
  * directly. Every one of those is an image; none of them is the Package's
97
97
  * problem.
98
98
  */
99
- export async function decodeWorkersAiImageV1(
99
+ export async function decodeNativeAiImageV1(
100
100
  answer: unknown,
101
101
  ): Promise<ArrayBuffer> {
102
102
  if (answer instanceof ArrayBuffer) return answer;
@@ -123,15 +123,15 @@ export async function decodeWorkersAiImageV1(
123
123
  }
124
124
 
125
125
  /**
126
- * The Workers AI binding as an {@link ImageModelV1}.
126
+ * The native AI binding as an {@link ImageModelV1}.
127
127
  *
128
128
  * `width` and `height` are passed through: the Stable Diffusion models accept
129
129
  * them, and the FLUX models ignore unknown fields rather than refusing. The
130
130
  * Package never trusts them to have been honoured — it reads the dimensions
131
131
  * back out of the bytes.
132
132
  */
133
- export function createWorkersAiImageModelV1(
134
- binding: WorkersAiBindingV1,
133
+ export function createNativeAiImageModelV1(
134
+ binding: NativeAiBindingV1,
135
135
  ): ImageModelV1 {
136
136
  return {
137
137
  async run(model: string, input: ImageModelInputV1): Promise<ArrayBuffer> {
@@ -140,7 +140,7 @@ export function createWorkersAiImageModelV1(
140
140
  width: input.width,
141
141
  height: input.height,
142
142
  });
143
- return await decodeWorkersAiImageV1(answer);
143
+ return await decodeNativeAiImageV1(answer);
144
144
  },
145
145
  };
146
146
  }
@@ -174,7 +174,7 @@ export function createBotImageHost(
174
174
  runId: turn.runId,
175
175
  },
176
176
  files,
177
- ...(bindings.AI ? { model: createWorkersAiImageModelV1(bindings.AI) } : {}),
177
+ ...(bindings.AI ? { model: createNativeAiImageModelV1(bindings.AI) } : {}),
178
178
  ...(modelId ? { modelId } : {}),
179
179
  };
180
180
  }
@@ -5,26 +5,24 @@ import type {
5
5
  } from "@frockbot/kernel-contracts";
6
6
  import {
7
7
  createIsolateCapabilityHost,
8
+ isolateBindingDigestV1,
8
9
  isolateModelEventStreamV1,
9
10
  ISOLATE_MODEL_FAILURE_MESSAGE,
10
11
  ISOLATE_MODEL_REQUEST_PREFIX,
11
- matchingModelAssignmentV1,
12
- type IsolateAssignmentV1,
12
+ matchingModelCapabilityV1,
13
+ type IsolateCapabilityV1,
13
14
  type IsolateModelBindingV1,
14
15
  type IsolateModelRequestRecordV1,
15
16
  } from "./backend-isolate.ts";
16
17
 
17
- const ASSIGNMENT: IsolateAssignmentV1 = {
18
- assignmentId: "model-assignment",
18
+ const CAPABILITY: IsolateCapabilityV1 = {
19
19
  packageId: "provider-ollama-cloud",
20
20
  capabilityId: "ollama-cloud-models",
21
21
  kind: "model",
22
22
  connectionId: "connection-1",
23
- providerModelId: "glm-5.3-flash:cloud",
24
23
  };
25
24
 
26
25
  const BINDING: IsolateModelBindingV1 = {
27
- assignmentId: "model-assignment",
28
26
  packageId: "provider-ollama-cloud",
29
27
  capabilityId: "ollama-cloud-models",
30
28
  connectionId: "connection-1",
@@ -69,14 +67,19 @@ function memoryStorage() {
69
67
 
70
68
  function host(
71
69
  options: {
72
- binding?: IsolateModelBindingV1;
70
+ binding?: IsolateModelBindingV1 | null;
71
+ capabilities?: IsolateCapabilityV1[];
72
+ unavailableModelBinding?: {
73
+ provider?: string;
74
+ providerModelId: string;
75
+ };
73
76
  stream?: (request: NormalizedModelRequest) => AsyncIterable<LlmStreamEvent>;
74
77
  } = {},
75
78
  ) {
76
79
  const storage = memoryStorage();
77
80
  const forwarded: NormalizedModelRequest[] = [];
78
81
  let minted = 0;
79
- const binding = options.binding ?? BINDING;
82
+ const binding = options.binding === undefined ? BINDING : options.binding;
80
83
  return {
81
84
  storage,
82
85
  forwarded,
@@ -85,55 +88,65 @@ function host(
85
88
  botId: "bot-1",
86
89
  packageId: "bot-authored",
87
90
  generationId: "generation-1",
88
- assignments: [ASSIGNMENT],
89
- modelBinding: binding,
90
- modelPath: {
91
- stream: (value) => {
92
- forwarded.push(structuredClone(value));
93
- return (
94
- options.stream?.(value) ??
95
- (async function* () {
96
- yield { type: "text-delta", text: "hi" } as LlmStreamEvent;
97
- })()
98
- );
99
- },
100
- },
91
+ capabilities: options.capabilities ?? [CAPABILITY],
92
+ ...(binding
93
+ ? {
94
+ modelBinding: binding,
95
+ modelPath: {
96
+ stream: (value: NormalizedModelRequest) => {
97
+ forwarded.push(structuredClone(value));
98
+ return (
99
+ options.stream?.(value) ??
100
+ (async function* () {
101
+ yield {
102
+ type: "text-delta",
103
+ text: "hi",
104
+ } as LlmStreamEvent;
105
+ })()
106
+ );
107
+ },
108
+ },
109
+ }
110
+ : {}),
111
+ ...(options.unavailableModelBinding
112
+ ? { unavailableModelBinding: options.unavailableModelBinding }
113
+ : {}),
101
114
  newId: () => `minted-${(minted += 1)}`,
102
115
  now: () => new Date("2026-08-31T00:00:00.000Z"),
103
116
  }),
104
117
  };
105
118
  }
106
119
 
107
- describe("an isolate model request is bound to its Assignment", () => {
108
- test("matches only the exact assigned provider and model", () => {
109
- expect(
110
- matchingModelAssignmentV1([ASSIGNMENT], BINDING, request()),
111
- ).toMatchObject({ assignmentId: "model-assignment" });
120
+ describe("an isolate model request is bound to its enabled Capability", () => {
121
+ test("matches only the exact authoritative provider and model", () => {
122
+ expect(matchingModelCapabilityV1([CAPABILITY], BINDING, request())).toEqual(
123
+ CAPABILITY,
124
+ );
112
125
  expect(
113
- matchingModelAssignmentV1(
114
- [ASSIGNMENT],
126
+ matchingModelCapabilityV1(
127
+ [CAPABILITY],
115
128
  BINDING,
116
129
  request({ provider: "foundation" }),
117
130
  ),
118
131
  ).toBeUndefined();
119
132
  expect(
120
- matchingModelAssignmentV1(
121
- [ASSIGNMENT],
133
+ matchingModelCapabilityV1(
134
+ [CAPABILITY],
122
135
  BINDING,
123
136
  request({ model: "some-other-model" }),
124
137
  ),
125
138
  ).toBeUndefined();
126
- // No durable binding at all: an enabled Assignment on its own authorizes
139
+ // No authoritative binding at all: an enabled Capability on its own authorizes
127
140
  // nothing.
128
141
  expect(
129
- matchingModelAssignmentV1([ASSIGNMENT], undefined, request()),
142
+ matchingModelCapabilityV1([CAPABILITY], undefined, request()),
130
143
  ).toBeUndefined();
131
144
  });
132
145
 
133
146
  test("ignores a Bot-supplied model binding", () => {
134
147
  expect(
135
- matchingModelAssignmentV1(
136
- [ASSIGNMENT],
148
+ matchingModelCapabilityV1(
149
+ [CAPABILITY],
137
150
  BINDING,
138
151
  request({
139
152
  provider: "foundation",
@@ -144,7 +157,7 @@ describe("an isolate model request is bound to its Assignment", () => {
144
157
  ).toBeUndefined();
145
158
  });
146
159
 
147
- test("a request for an unassigned provider is a pending decision", async () => {
160
+ test("a request outside the effective binding is a pending decision", async () => {
148
161
  const subject = host();
149
162
  const outcome = await subject.host.invokeModel(
150
163
  request({ provider: "foundation", model: "deterministic-v1" }),
@@ -155,6 +168,53 @@ describe("an isolate model request is bound to its Assignment", () => {
155
168
  expect(subject.forwarded).toHaveLength(0);
156
169
  });
157
170
 
171
+ test("a request with no durable model binding is a pending decision", async () => {
172
+ const subject = host({ binding: null, capabilities: [] });
173
+
174
+ const outcome = await subject.host.invokeModel(request());
175
+
176
+ expect(outcome).toMatchObject({ status: "pending-user-decision" });
177
+ expect(await subject.host.pendingDecisions()).toHaveLength(1);
178
+ expect(await subject.host.recordedModelRequests()).toHaveLength(0);
179
+ expect(subject.forwarded).toHaveLength(0);
180
+ });
181
+
182
+ test("a held model binding with an unavailable Connection is unavailable", async () => {
183
+ const subject = host({
184
+ binding: null,
185
+ capabilities: [],
186
+ unavailableModelBinding: {
187
+ provider: BINDING.provider,
188
+ providerModelId: BINDING.providerModelId,
189
+ },
190
+ });
191
+
192
+ const outcome = await subject.host.invokeModel(request());
193
+
194
+ expect(outcome).toMatchObject({ status: "unavailable" });
195
+ expect(await subject.host.pendingDecisions()).toHaveLength(0);
196
+ expect(await subject.host.recordedModelRequests()).toHaveLength(0);
197
+ expect(subject.forwarded).toHaveLength(0);
198
+ });
199
+
200
+ test("an unavailable binding does not cover another provider", async () => {
201
+ const subject = host({
202
+ binding: null,
203
+ capabilities: [],
204
+ unavailableModelBinding: {
205
+ provider: BINDING.provider,
206
+ providerModelId: BINDING.providerModelId,
207
+ },
208
+ });
209
+
210
+ const outcome = await subject.host.invokeModel(
211
+ request({ provider: "foundation" }),
212
+ );
213
+
214
+ expect(outcome).toMatchObject({ status: "pending-user-decision" });
215
+ expect(await subject.host.pendingDecisions()).toHaveLength(1);
216
+ });
217
+
158
218
  test("forwards the authority's binding, never the Bot's", async () => {
159
219
  const subject = host();
160
220
  await subject.host.invokeModel(
@@ -173,6 +233,92 @@ describe("an isolate model request is bound to its Assignment", () => {
173
233
  });
174
234
  });
175
235
 
236
+ describe("User-enabled isolate bindings", () => {
237
+ test("list projects the complete enabled set", async () => {
238
+ const subject = host();
239
+
240
+ await expect(subject.host.list()).resolves.toEqual([
241
+ { capabilityId: "ollama-cloud-models", kind: "model" },
242
+ ]);
243
+ });
244
+
245
+ test("identity, generation, and the enabled set are binding digest inputs", async () => {
246
+ const first = await isolateBindingDigestV1({
247
+ userId: "user-1",
248
+ botId: "bot-1",
249
+ generationId: "generation-1",
250
+ capabilities: [CAPABILITY],
251
+ });
252
+ const same = await isolateBindingDigestV1({
253
+ userId: "user-1",
254
+ botId: "bot-1",
255
+ generationId: "generation-1",
256
+ capabilities: [structuredClone(CAPABILITY)],
257
+ });
258
+ const otherUser = await isolateBindingDigestV1({
259
+ userId: "user-2",
260
+ botId: "bot-1",
261
+ generationId: "generation-1",
262
+ capabilities: [CAPABILITY],
263
+ });
264
+ const otherBot = await isolateBindingDigestV1({
265
+ userId: "user-1",
266
+ botId: "bot-2",
267
+ generationId: "generation-1",
268
+ capabilities: [CAPABILITY],
269
+ });
270
+ const otherGeneration = await isolateBindingDigestV1({
271
+ userId: "user-1",
272
+ botId: "bot-1",
273
+ generationId: "generation-2",
274
+ capabilities: [CAPABILITY],
275
+ });
276
+ const widened = await isolateBindingDigestV1({
277
+ userId: "user-1",
278
+ botId: "bot-1",
279
+ generationId: "generation-1",
280
+ capabilities: [
281
+ CAPABILITY,
282
+ {
283
+ packageId: "clock",
284
+ capabilityId: "clock",
285
+ kind: "tool",
286
+ },
287
+ ],
288
+ });
289
+
290
+ expect(same).toBe(first);
291
+ expect(otherUser).not.toBe(first);
292
+ expect(otherBot).not.toBe(first);
293
+ expect(otherGeneration).not.toBe(first);
294
+ expect(widened).not.toBe(first);
295
+ });
296
+
297
+ test("ordering does not change the digest", async () => {
298
+ const clock: IsolateCapabilityV1 = {
299
+ packageId: "clock",
300
+ capabilityId: "clock",
301
+ kind: "tool",
302
+ };
303
+
304
+ await expect(
305
+ isolateBindingDigestV1({
306
+ userId: "user-1",
307
+ botId: "bot-1",
308
+ generationId: "generation-1",
309
+ capabilities: [CAPABILITY, clock],
310
+ }),
311
+ ).resolves.toBe(
312
+ await isolateBindingDigestV1({
313
+ userId: "user-1",
314
+ botId: "bot-1",
315
+ generationId: "generation-1",
316
+ capabilities: [clock, CAPABILITY],
317
+ }),
318
+ );
319
+ });
320
+ });
321
+
176
322
  describe("the isolate model request record", () => {
177
323
  test("two invocations reusing one Bot request id produce two records", async () => {
178
324
  const subject = host();