@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.
- package/frockbot.json +4 -0
- package/package.json +30 -28
- package/src/agent.ts +10 -13
- package/src/backend-authoring.test.ts +356 -2
- package/src/backend-authoring.ts +585 -20
- package/src/backend-composition-input.test.ts +65 -0
- package/src/backend-composition-input.ts +58 -0
- package/src/backend-composition.ts +23 -5
- package/src/backend-configuration.test.ts +549 -1468
- package/src/backend-contracts.test.ts +28 -0
- package/src/backend-contracts.ts +3 -1
- package/src/backend-execution.ts +0 -4
- package/src/backend-iframe-ui.test.ts +131 -0
- package/src/backend-image.test.ts +8 -8
- package/src/backend-image.ts +13 -13
- package/src/backend-isolate.test.ts +230 -89
- package/src/backend-isolate.ts +103 -200
- package/src/backend-package-catalog.test.ts +451 -0
- package/src/backend-package-catalog.ts +923 -0
- package/src/backend-recovery-integration.test.ts +17 -67
- package/src/backend-routines.ts +1 -1
- package/src/backend-runner-iframe.test.ts +74 -0
- package/src/backend-runner.ts +192 -0
- package/src/backend.ts +1198 -1781
- package/src/client/FrockBotApp.vue +44 -73
- package/src/client/PackageIframeHost.vue +218 -0
- package/src/client/PackageIframeSettings.vue +52 -0
- package/src/client/SendPayloadView.vue +0 -60
- package/src/client/index.test.ts +439 -380
- package/src/client/index.ts +163 -257
- package/src/client/model-presentation.test.ts +21 -8
- package/src/client/model-presentation.ts +14 -7
- package/src/client/package-iframe-host-message.test.ts +41 -0
- package/src/client/package-iframe-host-message.ts +27 -0
- package/src/client/styles.css +0 -27
- package/src/composition-views.ts +54 -0
- package/src/settings-links.test.ts +2 -10
- package/src/settings-links.ts +1 -13
- package/src/shared.ts +10 -13
- package/src/backend-assignment.test.ts +0 -161
- package/src/backend-assignment.ts +0 -274
|
@@ -1,38 +1,42 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type {
|
|
3
|
+
IsolateConnectionV1,
|
|
3
4
|
LlmStreamEvent,
|
|
4
5
|
NormalizedModelRequest,
|
|
5
6
|
} from "@frockbot/kernel-contracts";
|
|
6
7
|
import {
|
|
7
8
|
createIsolateCapabilityHost,
|
|
9
|
+
isolateBindingDigestV1,
|
|
8
10
|
isolateModelEventStreamV1,
|
|
9
11
|
ISOLATE_MODEL_FAILURE_MESSAGE,
|
|
10
12
|
ISOLATE_MODEL_REQUEST_PREFIX,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
matchesAdmittedConnectionV1,
|
|
14
|
+
matchingModelBindingV1,
|
|
13
15
|
type IsolateModelBindingV1,
|
|
14
16
|
type IsolateModelRequestRecordV1,
|
|
15
17
|
} from "./backend-isolate.ts";
|
|
16
18
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
19
|
+
const CONNECTIONS: IsolateConnectionV1[] = [
|
|
20
|
+
{
|
|
21
|
+
connectionId: "connection-1",
|
|
22
|
+
packageId: "provider-ollama-cloud",
|
|
23
|
+
connectionTypeId: "ollama-cloud-account",
|
|
24
|
+
displayName: "Work",
|
|
25
|
+
generation: "generation-1",
|
|
26
|
+
safeMetadata: { region: "au" },
|
|
27
|
+
},
|
|
28
|
+
];
|
|
25
29
|
|
|
26
30
|
const BINDING: IsolateModelBindingV1 = {
|
|
27
|
-
assignmentId: "model-assignment",
|
|
28
|
-
packageId: "provider-ollama-cloud",
|
|
29
|
-
capabilityId: "ollama-cloud-models",
|
|
30
31
|
connectionId: "connection-1",
|
|
32
|
+
packageId: "provider-ollama-cloud",
|
|
31
33
|
provider: "ollama-cloud",
|
|
32
34
|
providerModelId: "glm-5.3-flash:cloud",
|
|
33
35
|
connectionGeneration: "generation-1",
|
|
34
36
|
};
|
|
35
37
|
|
|
38
|
+
const IDENTITY = { userId: "user-1", botId: "bot-1" } as const;
|
|
39
|
+
|
|
36
40
|
function request(
|
|
37
41
|
overrides: Partial<NormalizedModelRequest> = {},
|
|
38
42
|
): NormalizedModelRequest {
|
|
@@ -43,6 +47,10 @@ function request(
|
|
|
43
47
|
system: "",
|
|
44
48
|
messages: [{ role: "user", content: "hello" }],
|
|
45
49
|
tools: [],
|
|
50
|
+
modelBinding: {
|
|
51
|
+
connectionId: BINDING.connectionId,
|
|
52
|
+
connectionGeneration: BINDING.connectionGeneration,
|
|
53
|
+
},
|
|
46
54
|
...overrides,
|
|
47
55
|
};
|
|
48
56
|
}
|
|
@@ -55,7 +63,6 @@ function memoryStorage() {
|
|
|
55
63
|
values.set(key, structuredClone(value));
|
|
56
64
|
return Promise.resolve();
|
|
57
65
|
},
|
|
58
|
-
get: <T>(key: string) => Promise.resolve(values.get(key) as T | undefined),
|
|
59
66
|
list: <T>(options: { prefix: string }) =>
|
|
60
67
|
Promise.resolve(
|
|
61
68
|
new Map(
|
|
@@ -69,123 +76,259 @@ function memoryStorage() {
|
|
|
69
76
|
|
|
70
77
|
function host(
|
|
71
78
|
options: {
|
|
79
|
+
packageId?: string;
|
|
72
80
|
binding?: IsolateModelBindingV1;
|
|
81
|
+
withoutModel?: boolean;
|
|
73
82
|
stream?: (request: NormalizedModelRequest) => AsyncIterable<LlmStreamEvent>;
|
|
74
83
|
} = {},
|
|
75
84
|
) {
|
|
76
85
|
const storage = memoryStorage();
|
|
77
86
|
const forwarded: NormalizedModelRequest[] = [];
|
|
78
87
|
let minted = 0;
|
|
79
|
-
const binding = options.binding ?? BINDING;
|
|
80
88
|
return {
|
|
81
89
|
storage,
|
|
82
90
|
forwarded,
|
|
83
91
|
host: createIsolateCapabilityHost({
|
|
84
92
|
storage,
|
|
85
93
|
botId: "bot-1",
|
|
86
|
-
packageId: "bot-authored",
|
|
87
|
-
generationId: "
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
packageId: options.packageId ?? "bot-authored",
|
|
95
|
+
generationId: "composition-1",
|
|
96
|
+
connections: CONNECTIONS,
|
|
97
|
+
...(!options.withoutModel
|
|
98
|
+
? { modelBinding: options.binding ?? BINDING }
|
|
99
|
+
: {}),
|
|
100
|
+
...(!options.withoutModel
|
|
101
|
+
? {
|
|
102
|
+
modelPath: {
|
|
103
|
+
stream: (value: NormalizedModelRequest) => {
|
|
104
|
+
forwarded.push(structuredClone(value));
|
|
105
|
+
return (
|
|
106
|
+
options.stream?.(value) ??
|
|
107
|
+
(async function* () {
|
|
108
|
+
yield { type: "text-delta", text: "hi" } as LlmStreamEvent;
|
|
109
|
+
})()
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
: {}),
|
|
115
|
+
memory: true,
|
|
116
|
+
workspace: true,
|
|
101
117
|
newId: () => `minted-${(minted += 1)}`,
|
|
102
118
|
now: () => new Date("2026-08-31T00:00:00.000Z"),
|
|
103
119
|
}),
|
|
104
120
|
};
|
|
105
121
|
}
|
|
106
122
|
|
|
107
|
-
describe("
|
|
108
|
-
test("
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
).
|
|
112
|
-
expect(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
describe("per-Bot isolate authority", () => {
|
|
124
|
+
test("two Packages of one Bot see the same Connections and capabilities", async () => {
|
|
125
|
+
const left = await host({ packageId: "package-left" }).host.list();
|
|
126
|
+
const right = await host({ packageId: "package-right" }).host.list();
|
|
127
|
+
expect(left).toEqual(right);
|
|
128
|
+
expect(left).toMatchObject({
|
|
129
|
+
status: "available",
|
|
130
|
+
connections: [
|
|
131
|
+
{ connectionId: "connection-1", generation: "generation-1" },
|
|
132
|
+
],
|
|
133
|
+
tools: true,
|
|
134
|
+
memory: true,
|
|
135
|
+
workspace: true,
|
|
136
|
+
notify: true,
|
|
137
|
+
schedule: true,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("matches only the Bot's configured provider and model", () => {
|
|
142
|
+
expect(matchingModelBindingV1(BINDING, request())).toEqual(BINDING);
|
|
119
143
|
expect(
|
|
120
|
-
|
|
121
|
-
[ASSIGNMENT],
|
|
122
|
-
BINDING,
|
|
123
|
-
request({ model: "some-other-model" }),
|
|
124
|
-
),
|
|
144
|
+
matchingModelBindingV1(BINDING, request({ provider: "foundation" })),
|
|
125
145
|
).toBeUndefined();
|
|
126
|
-
// No durable binding at all: an enabled Assignment on its own authorizes
|
|
127
|
-
// nothing.
|
|
128
146
|
expect(
|
|
129
|
-
|
|
147
|
+
matchingModelBindingV1(BINDING, request({ model: "other" })),
|
|
130
148
|
).toBeUndefined();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("ignores a Bot-supplied model binding", () => {
|
|
134
149
|
expect(
|
|
135
|
-
|
|
136
|
-
[ASSIGNMENT],
|
|
150
|
+
matchingModelBindingV1(
|
|
137
151
|
BINDING,
|
|
138
152
|
request({
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
modelBinding: {
|
|
154
|
+
connectionId: BINDING.connectionId,
|
|
155
|
+
connectionGeneration: "generation-2",
|
|
156
|
+
},
|
|
142
157
|
}),
|
|
143
158
|
),
|
|
144
159
|
).toBeUndefined();
|
|
160
|
+
expect(matchingModelBindingV1(undefined, request())).toBeUndefined();
|
|
145
161
|
});
|
|
146
162
|
|
|
147
|
-
test("a
|
|
148
|
-
const subject = host();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
expect(outcome).toMatchObject({ status: "pending-user-decision" });
|
|
163
|
+
test("a Bot with no configured model gets unavailable, never a pending decision", async () => {
|
|
164
|
+
const subject = host({ withoutModel: true });
|
|
165
|
+
await expect(subject.host.invokeModel(request())).resolves.toEqual({
|
|
166
|
+
status: "unavailable",
|
|
167
|
+
reason: "this Bot has no configured model",
|
|
168
|
+
});
|
|
154
169
|
expect(await subject.host.recordedModelRequests()).toHaveLength(0);
|
|
155
170
|
expect(subject.forwarded).toHaveLength(0);
|
|
156
171
|
});
|
|
157
172
|
|
|
158
|
-
test("
|
|
173
|
+
test("a request that does not match the configured model is unavailable", async () => {
|
|
159
174
|
const subject = host();
|
|
160
|
-
await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
await expect(
|
|
176
|
+
subject.host.invokeModel(
|
|
177
|
+
request({ provider: "foundation", model: "deterministic-v1" }),
|
|
178
|
+
),
|
|
179
|
+
).resolves.toEqual({
|
|
180
|
+
status: "unavailable",
|
|
181
|
+
reason: "the request does not match this Bot's configured model",
|
|
182
|
+
});
|
|
183
|
+
expect(await subject.host.recordedModelRequests()).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("refuses a model binding outside the admitted snapshot", async () => {
|
|
187
|
+
const subject = host();
|
|
188
|
+
await expect(
|
|
189
|
+
subject.host.invokeModel(
|
|
190
|
+
request({
|
|
191
|
+
modelBinding: {
|
|
192
|
+
connectionId: "forged-connection",
|
|
193
|
+
connectionGeneration: "forged-generation",
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
).resolves.toEqual({
|
|
198
|
+
status: "unavailable",
|
|
199
|
+
reason: "the request does not match this Bot's configured model",
|
|
200
|
+
});
|
|
201
|
+
expect(subject.forwarded).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an old isolate cannot receive a newly added or regenerated Connection", () => {
|
|
205
|
+
const lease = {
|
|
206
|
+
status: "available" as const,
|
|
207
|
+
leaseId: "lease-1",
|
|
208
|
+
connectionId: "connection-1",
|
|
209
|
+
generation: "generation-1",
|
|
210
|
+
expiresAt: "2026-08-31T00:05:00.000Z",
|
|
211
|
+
};
|
|
212
|
+
expect(matchesAdmittedConnectionV1(CONNECTIONS[0], lease)).toBe(true);
|
|
213
|
+
expect(matchesAdmittedConnectionV1(undefined, lease)).toBe(false);
|
|
214
|
+
expect(
|
|
215
|
+
matchesAdmittedConnectionV1(CONNECTIONS[0], {
|
|
216
|
+
...lease,
|
|
217
|
+
generation: "generation-2",
|
|
218
|
+
}),
|
|
219
|
+
).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("isolate binding digest", () => {
|
|
224
|
+
test("is order-independent for the same Bot authority", async () => {
|
|
225
|
+
const another = {
|
|
226
|
+
...CONNECTIONS[0]!,
|
|
227
|
+
connectionId: "connection-2",
|
|
228
|
+
generation: "generation-2",
|
|
229
|
+
};
|
|
230
|
+
const input = {
|
|
231
|
+
model: BINDING,
|
|
232
|
+
compositionGenerationId: "composition-1",
|
|
233
|
+
};
|
|
234
|
+
await expect(
|
|
235
|
+
isolateBindingDigestV1({
|
|
236
|
+
...IDENTITY,
|
|
237
|
+
...input,
|
|
238
|
+
connections: [CONNECTIONS[0]!, another],
|
|
239
|
+
}),
|
|
240
|
+
).resolves.toBe(
|
|
241
|
+
await isolateBindingDigestV1({
|
|
242
|
+
...IDENTITY,
|
|
243
|
+
...input,
|
|
244
|
+
connections: [another, CONNECTIONS[0]!],
|
|
166
245
|
}),
|
|
167
246
|
);
|
|
247
|
+
});
|
|
168
248
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
249
|
+
test("changes with User and Bot identity", async () => {
|
|
250
|
+
const input = {
|
|
251
|
+
connections: CONNECTIONS,
|
|
252
|
+
model: BINDING,
|
|
253
|
+
compositionGenerationId: "composition-1",
|
|
254
|
+
};
|
|
255
|
+
const base = await isolateBindingDigestV1({ ...IDENTITY, ...input });
|
|
256
|
+
const otherUser = await isolateBindingDigestV1({
|
|
257
|
+
userId: "user-2",
|
|
258
|
+
botId: IDENTITY.botId,
|
|
259
|
+
...input,
|
|
260
|
+
});
|
|
261
|
+
const otherBot = await isolateBindingDigestV1({
|
|
262
|
+
userId: IDENTITY.userId,
|
|
263
|
+
botId: "bot-2",
|
|
264
|
+
...input,
|
|
172
265
|
});
|
|
266
|
+
|
|
267
|
+
expect(otherUser).not.toBe(base);
|
|
268
|
+
expect(otherBot).not.toBe(base);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("changes when a Connection is added, removed, or regenerated", async () => {
|
|
272
|
+
const base = await isolateBindingDigestV1({
|
|
273
|
+
...IDENTITY,
|
|
274
|
+
connections: CONNECTIONS,
|
|
275
|
+
model: BINDING,
|
|
276
|
+
compositionGenerationId: "composition-1",
|
|
277
|
+
});
|
|
278
|
+
const added = await isolateBindingDigestV1({
|
|
279
|
+
...IDENTITY,
|
|
280
|
+
connections: [
|
|
281
|
+
...CONNECTIONS,
|
|
282
|
+
{ ...CONNECTIONS[0]!, connectionId: "connection-2" },
|
|
283
|
+
],
|
|
284
|
+
model: BINDING,
|
|
285
|
+
compositionGenerationId: "composition-1",
|
|
286
|
+
});
|
|
287
|
+
const removed = await isolateBindingDigestV1({
|
|
288
|
+
...IDENTITY,
|
|
289
|
+
connections: [],
|
|
290
|
+
model: BINDING,
|
|
291
|
+
compositionGenerationId: "composition-1",
|
|
292
|
+
});
|
|
293
|
+
const regenerated = await isolateBindingDigestV1({
|
|
294
|
+
...IDENTITY,
|
|
295
|
+
connections: [{ ...CONNECTIONS[0]!, generation: "generation-2" }],
|
|
296
|
+
model: BINDING,
|
|
297
|
+
compositionGenerationId: "composition-1",
|
|
298
|
+
});
|
|
299
|
+
expect(new Set([base, added, removed, regenerated]).size).toBe(4);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("changes with the model binding and Composition generation", async () => {
|
|
303
|
+
const base = await isolateBindingDigestV1({
|
|
304
|
+
...IDENTITY,
|
|
305
|
+
connections: CONNECTIONS,
|
|
306
|
+
model: BINDING,
|
|
307
|
+
compositionGenerationId: "composition-1",
|
|
308
|
+
});
|
|
309
|
+
const model = await isolateBindingDigestV1({
|
|
310
|
+
...IDENTITY,
|
|
311
|
+
connections: CONNECTIONS,
|
|
312
|
+
model: { ...BINDING, providerModelId: "other" },
|
|
313
|
+
compositionGenerationId: "composition-1",
|
|
314
|
+
});
|
|
315
|
+
const composition = await isolateBindingDigestV1({
|
|
316
|
+
...IDENTITY,
|
|
317
|
+
connections: CONNECTIONS,
|
|
318
|
+
model: BINDING,
|
|
319
|
+
compositionGenerationId: "composition-2",
|
|
320
|
+
});
|
|
321
|
+
expect(new Set([base, model, composition]).size).toBe(3);
|
|
173
322
|
});
|
|
174
323
|
});
|
|
175
324
|
|
|
176
325
|
describe("the isolate model request record", () => {
|
|
177
|
-
test("two invocations reusing one
|
|
326
|
+
test("two invocations reusing one request id produce two records", async () => {
|
|
178
327
|
const subject = host();
|
|
179
|
-
|
|
180
328
|
await subject.host.invokeModel(request());
|
|
181
329
|
await subject.host.invokeModel(request());
|
|
182
|
-
|
|
183
330
|
const recorded = await subject.host.recordedModelRequests();
|
|
184
331
|
expect(recorded).toHaveLength(2);
|
|
185
|
-
expect(recorded.map((entry) => entry.requestId)).toEqual([
|
|
186
|
-
"request-1",
|
|
187
|
-
"request-1",
|
|
188
|
-
]);
|
|
189
332
|
expect(new Set(recorded.map((entry) => entry.recordId)).size).toBe(2);
|
|
190
333
|
expect(
|
|
191
334
|
[...subject.storage.values.keys()].filter((key) =>
|
|
@@ -194,23 +337,22 @@ describe("the isolate model request record", () => {
|
|
|
194
337
|
).toHaveLength(2);
|
|
195
338
|
});
|
|
196
339
|
|
|
197
|
-
test("refuses an unbounded
|
|
198
|
-
const subject = host();
|
|
340
|
+
test("refuses an unbounded request id", async () => {
|
|
199
341
|
await expect(
|
|
200
|
-
|
|
342
|
+
host().host.invokeModel(request({ requestId: "r".repeat(257) })),
|
|
201
343
|
).rejects.toThrow("requestId is not bounded");
|
|
202
344
|
});
|
|
203
345
|
|
|
204
|
-
test("records the request that was forwarded", async () => {
|
|
205
|
-
const subject = host();
|
|
346
|
+
test("records the request that was forwarded and attributes the Package", async () => {
|
|
347
|
+
const subject = host({ packageId: "bot-authored" });
|
|
206
348
|
await subject.host.invokeModel(request());
|
|
207
349
|
const recorded = (await subject.host.recordedModelRequests())[0] as
|
|
208
350
|
IsolateModelRequestRecordV1 | undefined;
|
|
351
|
+
expect(recorded?.packageId).toBe("bot-authored");
|
|
209
352
|
expect(recorded?.request.modelBinding).toEqual({
|
|
210
353
|
connectionId: "connection-1",
|
|
211
354
|
connectionGeneration: "generation-1",
|
|
212
355
|
});
|
|
213
|
-
expect(recorded?.capabilityId).toBe("ollama-cloud-models");
|
|
214
356
|
});
|
|
215
357
|
});
|
|
216
358
|
|
|
@@ -226,7 +368,6 @@ describe("provider errors crossing into Bot code", () => {
|
|
|
226
368
|
);
|
|
227
369
|
const reader = stream.getReader();
|
|
228
370
|
await reader.read();
|
|
229
|
-
|
|
230
371
|
const failure = await reader.read().then(
|
|
231
372
|
() => undefined,
|
|
232
373
|
(error: unknown) => (error instanceof Error ? error.message : ""),
|