@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
@@ -0,0 +1,451 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CatalogEntryV1, CatalogIndexV1 } from "@frockbot/catalog-core";
3
+ import type {
4
+ UserConfigurationCommandV1,
5
+ UserSettingsViewV1,
6
+ } from "@frockbot/configuration-core";
7
+ import {
8
+ compositionArtifactSetHashV1,
9
+ decodeCompositionGenerationV1,
10
+ type CompositionGenerationV1,
11
+ } from "@frockbot/kernel-composition/generation";
12
+ import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
13
+ import {
14
+ authorshipManifestKey,
15
+ type AuthoredManifestRecordV1,
16
+ } from "@frockbot/plugin-authoring/records";
17
+ import {
18
+ createPackageCatalogHost,
19
+ type BotPackageCatalogReader,
20
+ type PackageCatalogCompositionStore,
21
+ type PackageCatalogStorage,
22
+ } from "./backend-package-catalog.ts";
23
+
24
+ const CONTENT_HASH = "b".repeat(64);
25
+ const MANIFEST_HASH = "a".repeat(64);
26
+ const UI_CONTENT_HASH = "d".repeat(64);
27
+ const UI_SIZE = 42;
28
+ const CREATED_AT = "2026-09-02T00:00:00.000Z";
29
+
30
+ const manifest = {
31
+ schemaVersion: 3 as const,
32
+ id: "parcel-tracking",
33
+ displayName: "Parcel tracking",
34
+ version: "0.0.1",
35
+ compatibility: { frockbot: "*" },
36
+ dependencies: {},
37
+ contributions: {
38
+ runtime: { entry: "./package.js", host: "bot-isolate" as const },
39
+ client: {
40
+ kind: "iframe" as const,
41
+ artifact: {
42
+ contentHash: UI_CONTENT_HASH,
43
+ size: UI_SIZE,
44
+ mediaType: "text/html" as const,
45
+ bundlerVersion: "frockbot-inline-html@1",
46
+ },
47
+ mounts: [{ slot: "frockbot.tool-result:track_parcel" }],
48
+ },
49
+ },
50
+ configuration: {
51
+ settings: [],
52
+ connectionTypes: [
53
+ {
54
+ id: "shipping-account",
55
+ displayName: "Shipping account",
56
+ allowMultiple: false,
57
+ authorization: { kind: "grant" as const, driverId: "shipping-oauth" },
58
+ capabilities: ["track"],
59
+ },
60
+ ],
61
+ capabilities: [
62
+ {
63
+ id: "track",
64
+ kind: "tool" as const,
65
+ connectionTypes: ["shipping-account"],
66
+ },
67
+ ],
68
+ },
69
+ tools: [
70
+ {
71
+ name: "track_parcel",
72
+ description: "Tracks a parcel.",
73
+ inputSchema: { type: "object" },
74
+ },
75
+ ],
76
+ permissions: [],
77
+ };
78
+
79
+ const entry: CatalogEntryV1 = {
80
+ schemaVersion: 1,
81
+ catalogId: "parcel-tracking",
82
+ packageId: "parcel-tracking",
83
+ displayName: "Parcel tracking",
84
+ description: "Tracks parcels across carriers.",
85
+ version: "0.0.1",
86
+ kind: "package",
87
+ manifestHash: MANIFEST_HASH,
88
+ tags: ["shipping", "tracking"],
89
+ servers: [],
90
+ setupFields: [],
91
+ skills: [],
92
+ bundle: {
93
+ contentHash: CONTENT_HASH,
94
+ size: 512,
95
+ mediaType: "application/javascript",
96
+ bundlerVersion: "catalog-test@1",
97
+ manifest,
98
+ },
99
+ };
100
+
101
+ const index: CatalogIndexV1 = {
102
+ schemaVersion: 1,
103
+ generation: "catalog-1",
104
+ entries: [
105
+ {
106
+ catalogId: entry.catalogId,
107
+ packageId: entry.packageId,
108
+ displayName: entry.displayName,
109
+ description: entry.description,
110
+ version: entry.version,
111
+ kind: entry.kind,
112
+ manifestHash: entry.manifestHash,
113
+ contentHash: CONTENT_HASH,
114
+ tags: entry.tags,
115
+ },
116
+ ],
117
+ };
118
+
119
+ async function bootstrap(): Promise<CompositionGenerationV1> {
120
+ const members = [
121
+ {
122
+ packageId: "shell",
123
+ specifier: "@frockbot/plugin-shell",
124
+ version: "0.0.1",
125
+ manifestHash: "c".repeat(64),
126
+ provenance: {
127
+ kind: "first-party" as const,
128
+ packageId: "shell",
129
+ version: "0.0.1",
130
+ },
131
+ },
132
+ ];
133
+ return decodeCompositionGenerationV1({
134
+ schemaVersion: 1,
135
+ generationId: "bootstrap-generation",
136
+ artifactSetHash: await compositionArtifactSetHashV1(members),
137
+ createdAt: "2026-09-01T00:00:00.000Z",
138
+ origin: { kind: "bootstrap" },
139
+ members,
140
+ status: "active",
141
+ });
142
+ }
143
+
144
+ async function fixture(
145
+ options: { connected?: boolean; uiAvailable?: boolean } = {},
146
+ ) {
147
+ const log: string[] = [];
148
+ const records = new Map<string, unknown>();
149
+ const storage: PackageCatalogStorage = {
150
+ get: (key) => Promise.resolve(records.get(key) as never),
151
+ put: (values) => {
152
+ log.push("bot-intent-or-outcome");
153
+ for (const [key, value] of Object.entries(values))
154
+ records.set(key, value);
155
+ return Promise.resolve();
156
+ },
157
+ };
158
+ let good = await bootstrap();
159
+ const generations = new Map([[good.generationId, good]]);
160
+ const proposed: CompositionGenerationV1[] = [];
161
+ const composition: PackageCatalogCompositionStore = {
162
+ current: () => Promise.resolve(proposed.at(-1) ?? good),
163
+ lastKnownGood: () => Promise.resolve(good),
164
+ read: (generationId) => Promise.resolve(generations.get(generationId)),
165
+ propose: (generation) => {
166
+ log.push("composition-propose");
167
+ proposed.push(generation);
168
+ generations.set(generation.generationId, generation);
169
+ return Promise.resolve();
170
+ },
171
+ list: () =>
172
+ Promise.resolve({
173
+ generations: [...proposed].reverse().concat(good),
174
+ }),
175
+ revert: async (toGenerationId, origin, options) => {
176
+ const target = generations.get(toGenerationId)!;
177
+ const createdAt = options?.createdAt ?? CREATED_AT;
178
+ const generation = decodeCompositionGenerationV1({
179
+ ...target,
180
+ generationId: `${createdAt}:revert-catalog`,
181
+ parentGenerationId: (proposed.at(-1) ?? good).generationId,
182
+ createdAt,
183
+ origin,
184
+ status: "pending",
185
+ });
186
+ proposed.push(generation);
187
+ generations.set(generation.generationId, generation);
188
+ log.push("composition-revert");
189
+ return generation;
190
+ },
191
+ };
192
+ const catalog: BotPackageCatalogReader = {
193
+ readIndex: () => Promise.resolve(index),
194
+ readEntry: ({ catalogId }) =>
195
+ Promise.resolve(catalogId === entry.catalogId ? entry : undefined),
196
+ readSource: () => Promise.resolve(undefined),
197
+ headArtifact: () => Promise.resolve({ size: 512 }),
198
+ headUiArtifact: () =>
199
+ Promise.resolve(options.uiAvailable === false ? undefined : { size: 42 }),
200
+ };
201
+ let user: UserSettingsViewV1 = {
202
+ schemaVersion: 1,
203
+ revision: 0,
204
+ profile: { name: "User" },
205
+ packages: [],
206
+ connections: options.connected
207
+ ? [
208
+ {
209
+ connectionId: "shipping-1",
210
+ packageId: entry.packageId,
211
+ connectionTypeId: "shipping-account",
212
+ displayName: "Shipping",
213
+ state: "ready",
214
+ safeMetadata: {},
215
+ },
216
+ ]
217
+ : [],
218
+ catalogGeneration: "catalog-1",
219
+ catalogIndexHash: "d".repeat(64),
220
+ };
221
+ const commands: UserConfigurationCommandV1[] = [];
222
+ const userAuthority = {
223
+ read: () => Promise.resolve(structuredClone(user)),
224
+ execute: (command: UserConfigurationCommandV1) => {
225
+ log.push("user-effect");
226
+ commands.push(command);
227
+ user = {
228
+ ...user,
229
+ revision: user.revision + 1,
230
+ packages:
231
+ command.type === "user/uninstall-package"
232
+ ? user.packages.filter(
233
+ (candidate) => candidate.packageId !== command.packageId,
234
+ )
235
+ : command.type === "user/install-package"
236
+ ? [
237
+ {
238
+ packageId: command.packageId,
239
+ version: command.version,
240
+ state: "installed",
241
+ catalogId: command.catalogId,
242
+ catalogGeneration: command.catalogGeneration,
243
+ contentHash: command.contentHash,
244
+ provenance: "catalog",
245
+ },
246
+ ]
247
+ : user.packages,
248
+ };
249
+ return Promise.resolve({
250
+ schemaVersion: 1 as const,
251
+ commandId: command.commandId,
252
+ revision: user.revision,
253
+ status: "applied" as const,
254
+ });
255
+ },
256
+ };
257
+ const host = createPackageCatalogHost({
258
+ storage,
259
+ composition,
260
+ catalog,
261
+ user: userAuthority,
262
+ userId: "user-1",
263
+ botId: "bot-1",
264
+ runId: "run-1",
265
+ turnId: "turn-1",
266
+ now: () => new Date(CREATED_AT),
267
+ });
268
+ return {
269
+ host,
270
+ log,
271
+ records,
272
+ proposed,
273
+ commands,
274
+ get user() {
275
+ return user;
276
+ },
277
+ activateLatest() {
278
+ good = decodeCompositionGenerationV1({
279
+ ...proposed.at(-1)!,
280
+ status: "active",
281
+ });
282
+ generations.set(good.generationId, good);
283
+ proposed.length = 0;
284
+ },
285
+ };
286
+ }
287
+
288
+ describe("Bot Package Catalog host", () => {
289
+ test("searches the pinned index and inspects Connection readiness", async () => {
290
+ const { host } = await fixture();
291
+ await expect(host.search({ query: "shipping" })).resolves.toMatchObject({
292
+ generation: "catalog-1",
293
+ entries: [{ catalogId: "parcel-tracking" }],
294
+ });
295
+ await expect(
296
+ host.inspect({ catalogId: "parcel-tracking" }),
297
+ ).resolves.toMatchObject({
298
+ declaredTools: ["track_parcel"],
299
+ missingConnectionTypes: ["shipping-account"],
300
+ inert: true,
301
+ });
302
+ });
303
+
304
+ test("persists the iframe manifest before the User effect, appends a summary generation, and replays", async () => {
305
+ const test = await fixture();
306
+ const request = {
307
+ effectId: "catalog-effect-1",
308
+ sessionId: "user-1:bot-1",
309
+ position: { turn: 1, step: 1 },
310
+ change: {
311
+ action: "install" as const,
312
+ input: {
313
+ catalogId: "parcel-tracking",
314
+ contentHash: CONTENT_HASH,
315
+ summary: "Added parcel tracking",
316
+ },
317
+ },
318
+ };
319
+
320
+ const first = await test.host.change(request);
321
+ const replay = await test.host.change(request);
322
+
323
+ expect(first).toEqual(replay);
324
+ expect(test.commands).toHaveLength(1);
325
+ expect(test.log.indexOf("bot-intent-or-outcome")).toBeLessThan(
326
+ test.log.indexOf("user-effect"),
327
+ );
328
+ expect(test.proposed).toHaveLength(1);
329
+ expect(test.proposed[0]).toMatchObject({
330
+ summary: "Added parcel tracking",
331
+ status: "pending",
332
+ origin: { kind: "bot-catalog", action: "install" },
333
+ });
334
+ expect(test.proposed[0]?.members).toEqual(
335
+ expect.arrayContaining([
336
+ expect.objectContaining({
337
+ packageId: "parcel-tracking",
338
+ provenance: expect.objectContaining({
339
+ kind: "catalog",
340
+ contentHash: CONTENT_HASH,
341
+ }),
342
+ artifact: expect.objectContaining({ contentHash: CONTENT_HASH }),
343
+ }),
344
+ ]),
345
+ );
346
+ expect(first).toMatchObject({
347
+ missingConnectionTypes: ["shipping-account"],
348
+ });
349
+ const stored = test.records.get(
350
+ authorshipManifestKey(MANIFEST_HASH),
351
+ ) as AuthoredManifestRecordV1;
352
+ expect(
353
+ decodeFrockBotManifest(stored.manifest).contributions.client,
354
+ ).toEqual(manifest.contributions.client);
355
+ });
356
+
357
+ test("refuses an install whose manifest-referenced iframe artifact was not published", async () => {
358
+ const test = await fixture({ uiAvailable: false });
359
+
360
+ await expect(
361
+ test.host.change({
362
+ effectId: "catalog-missing-ui",
363
+ sessionId: "user-1:bot-1",
364
+ position: { turn: 1, step: 1 },
365
+ change: {
366
+ action: "install",
367
+ input: {
368
+ catalogId: "parcel-tracking",
369
+ contentHash: CONTENT_HASH,
370
+ },
371
+ },
372
+ }),
373
+ ).resolves.toMatchObject({
374
+ status: "refused",
375
+ reason: expect.stringContaining("iframe artifact"),
376
+ });
377
+ expect(test.commands).toHaveLength(0);
378
+ expect(test.proposed).toHaveLength(0);
379
+ });
380
+
381
+ test("hash mismatch and required-core removal are refused before User state changes", async () => {
382
+ const test = await fixture();
383
+ await expect(
384
+ test.host.change({
385
+ effectId: "catalog-wrong-hash",
386
+ sessionId: "user-1:bot-1",
387
+ position: { turn: 1, step: 1 },
388
+ change: {
389
+ action: "install",
390
+ input: {
391
+ catalogId: "parcel-tracking",
392
+ contentHash: "e".repeat(64),
393
+ },
394
+ },
395
+ }),
396
+ ).resolves.toMatchObject({
397
+ status: "refused",
398
+ reason: expect.stringContaining("not"),
399
+ });
400
+ await expect(
401
+ test.host.change({
402
+ effectId: "catalog-remove-core",
403
+ sessionId: "user-1:bot-1",
404
+ position: { turn: 1, step: 1 },
405
+ change: { action: "remove", input: { packageId: "shell" } },
406
+ }),
407
+ ).resolves.toMatchObject({
408
+ status: "refused",
409
+ reason: expect.stringContaining("required or non-Catalog"),
410
+ });
411
+ expect(test.commands).toHaveLength(0);
412
+ expect(test.proposed).toHaveLength(0);
413
+ });
414
+
415
+ test("package undo reverses an install in User state and appends a revert generation", async () => {
416
+ const test = await fixture();
417
+ await test.host.change({
418
+ effectId: "catalog-effect-install",
419
+ sessionId: "user-1:bot-1",
420
+ position: { turn: 1, step: 1 },
421
+ change: {
422
+ action: "install",
423
+ input: { catalogId: "parcel-tracking", contentHash: CONTENT_HASH },
424
+ },
425
+ });
426
+
427
+ const request = {
428
+ input: {},
429
+ effectId: "undo-catalog-effect",
430
+ sessionId: "user-1:bot-1",
431
+ position: { turn: 2, step: 1 },
432
+ };
433
+ const first = await test.host.undoCatalogChange(request);
434
+ const replay = await test.host.undoCatalogChange(request);
435
+
436
+ expect(first).toEqual(replay);
437
+ expect(first).toMatchObject({
438
+ status: "recorded",
439
+ targetGenerationId: "bootstrap-generation",
440
+ });
441
+ expect(test.commands.map((command) => command.type)).toEqual([
442
+ "user/install-package",
443
+ "user/uninstall-package",
444
+ ]);
445
+ expect(test.user.packages).toEqual([]);
446
+ expect(test.proposed.at(-1)).toMatchObject({
447
+ origin: { kind: "revert", revertsTo: "bootstrap-generation" },
448
+ status: "pending",
449
+ });
450
+ });
451
+ });