@frockbot/plugin-shell 0.3.25 → 0.3.27
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/package.json +32 -32
- package/src/backend-applets.test.ts +59 -0
- package/src/backend-applets.ts +35 -1
- package/src/backend-composition.test.ts +124 -0
- package/src/backend-composition.ts +9 -0
- package/src/backend.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -52,42 +52,42 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
55
|
-
"@frockbot/agent-runtime": "0.3.
|
|
56
|
-
"@frockbot/application-foundation": "0.3.
|
|
57
|
-
"@frockbot/catalog-core": "0.3.
|
|
58
|
-
"@frockbot/client-core": "0.3.
|
|
59
|
-
"@frockbot/client-ui": "0.3.
|
|
60
|
-
"@frockbot/computer-core": "0.3.
|
|
61
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
62
|
-
"@frockbot/configuration-core": "0.3.
|
|
63
|
-
"@frockbot/connection-core": "0.3.
|
|
64
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
65
|
-
"@frockbot/kernel-composition": "0.3.
|
|
66
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
67
|
-
"@frockbot/kernel-do": "0.3.
|
|
68
|
-
"@frockbot/machine-protocol": "0.3.
|
|
69
|
-
"@frockbot/plugin-applets": "0.3.
|
|
70
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
71
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
72
|
-
"@frockbot/plugin-computer": "0.3.
|
|
73
|
-
"@frockbot/plugin-flock": "0.3.
|
|
74
|
-
"@frockbot/plugin-image": "0.3.
|
|
75
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
76
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
77
|
-
"@frockbot/plugin-memory": "0.3.
|
|
78
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
79
|
-
"@frockbot/plugin-routines": "0.3.
|
|
80
|
-
"@frockbot/plugin-skills": "0.3.
|
|
81
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
82
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
83
|
-
"@frockbot/protocol": "0.3.
|
|
55
|
+
"@frockbot/agent-runtime": "0.3.27",
|
|
56
|
+
"@frockbot/application-foundation": "0.3.27",
|
|
57
|
+
"@frockbot/catalog-core": "0.3.27",
|
|
58
|
+
"@frockbot/client-core": "0.3.27",
|
|
59
|
+
"@frockbot/client-ui": "0.3.27",
|
|
60
|
+
"@frockbot/computer-core": "0.3.27",
|
|
61
|
+
"@frockbot/computer-host-protocol": "0.3.27",
|
|
62
|
+
"@frockbot/configuration-core": "0.3.27",
|
|
63
|
+
"@frockbot/connection-core": "0.3.27",
|
|
64
|
+
"@frockbot/kernel-agent-loop": "0.3.27",
|
|
65
|
+
"@frockbot/kernel-composition": "0.3.27",
|
|
66
|
+
"@frockbot/kernel-contracts": "0.3.27",
|
|
67
|
+
"@frockbot/kernel-do": "0.3.27",
|
|
68
|
+
"@frockbot/machine-protocol": "0.3.27",
|
|
69
|
+
"@frockbot/plugin-applets": "0.3.27",
|
|
70
|
+
"@frockbot/plugin-authoring": "0.3.27",
|
|
71
|
+
"@frockbot/plugin-bot-template": "0.3.27",
|
|
72
|
+
"@frockbot/plugin-computer": "0.3.27",
|
|
73
|
+
"@frockbot/plugin-flock": "0.3.27",
|
|
74
|
+
"@frockbot/plugin-image": "0.3.27",
|
|
75
|
+
"@frockbot/plugin-machine-messages": "0.3.27",
|
|
76
|
+
"@frockbot/plugin-mcp": "0.3.27",
|
|
77
|
+
"@frockbot/plugin-memory": "0.3.27",
|
|
78
|
+
"@frockbot/plugin-package-catalog": "0.3.27",
|
|
79
|
+
"@frockbot/plugin-routines": "0.3.27",
|
|
80
|
+
"@frockbot/plugin-skills": "0.3.27",
|
|
81
|
+
"@frockbot/plugin-subagents": "0.3.27",
|
|
82
|
+
"@frockbot/plugin-user-machine": "0.3.27",
|
|
83
|
+
"@frockbot/protocol": "0.3.27",
|
|
84
84
|
"cordis": "4.0.0-rc.8",
|
|
85
85
|
"vue": "3.5.41"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@cloudflare/workers-types": "latest",
|
|
89
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
90
|
-
"@frockbot/plugin-tools": "0.3.
|
|
89
|
+
"@frockbot/plugin-prompt": "0.3.27",
|
|
90
|
+
"@frockbot/plugin-tools": "0.3.27",
|
|
91
91
|
"@types/bun": "1.3.6",
|
|
92
92
|
"@types/node": "26.2.0",
|
|
93
93
|
"@vitejs/plugin-vue": "6.0.8",
|
|
@@ -526,6 +526,65 @@ describe("ctx.applets", () => {
|
|
|
526
526
|
);
|
|
527
527
|
});
|
|
528
528
|
|
|
529
|
+
// Production, 2026-09-04, run 76b4f8d9: `applet build` printed
|
|
530
|
+
// `dist/manifest.json 2 tool(s)`, the publish sync reported `ok` and moved
|
|
531
|
+
// nothing, and the publish then said "run `applet build` … first" about a
|
|
532
|
+
// file that was sitting on the Computer. A sync that reported `ok` is not a
|
|
533
|
+
// reason to blame the build when the sync itself says it saw the file.
|
|
534
|
+
test("publish names the hash the Computer held when the Workspace answers not-found", async () => {
|
|
535
|
+
const { host: capability } = host({
|
|
536
|
+
files: {
|
|
537
|
+
[`${APPLET}/dist/server.js`]: "export class Applet {}",
|
|
538
|
+
[`${APPLET}/dist/ui.html`]: "<h1>hi</h1>",
|
|
539
|
+
},
|
|
540
|
+
synced: {
|
|
541
|
+
status: "ok",
|
|
542
|
+
detail: "",
|
|
543
|
+
required: [
|
|
544
|
+
{
|
|
545
|
+
path: `${APPLET}/dist/server.js`,
|
|
546
|
+
contentHash: "a".repeat(64),
|
|
547
|
+
durable: true,
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
path: `${APPLET}/dist/ui.html`,
|
|
551
|
+
contentHash: "b".repeat(64),
|
|
552
|
+
durable: true,
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
path: `${APPLET}/dist/manifest.json`,
|
|
556
|
+
contentHash: "e0e3ce78fabf".padEnd(64, "0"),
|
|
557
|
+
durable: false,
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const outcome = await capability.publish({ appletId: APPLET }, scope);
|
|
564
|
+
|
|
565
|
+
expect(outcome.status).toBe("failed");
|
|
566
|
+
const reason = outcome.status === "failed" ? outcome.reason : "";
|
|
567
|
+
expect(reason).toBe(
|
|
568
|
+
'"dist/manifest.json" is on the Computer (sha256 e0e3ce78fabf) but the Workspace answered not-found: the publish sync reported ok and did not carry it',
|
|
569
|
+
);
|
|
570
|
+
expect(reason).not.toMatch(/applet build/);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("publish still blames the build when the sync saw no such file", async () => {
|
|
574
|
+
const { host: capability } = host({
|
|
575
|
+
files: {},
|
|
576
|
+
synced: {
|
|
577
|
+
status: "ok",
|
|
578
|
+
detail: "",
|
|
579
|
+
required: [{ path: `${APPLET}/dist/server.js`, durable: false }],
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
const outcome = await capability.publish({ appletId: APPLET }, scope);
|
|
583
|
+
expect(outcome.status === "failed" && outcome.reason).toMatch(
|
|
584
|
+
/applet build/,
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
|
|
529
588
|
test("publish refuses a manifest whose hashes do not match the bytes", async () => {
|
|
530
589
|
const { host: capability } = host({
|
|
531
590
|
files: {
|
package/src/backend-applets.ts
CHANGED
|
@@ -70,6 +70,19 @@ export interface AppletSourceSyncOutcomeV1 {
|
|
|
70
70
|
status: "ok" | "degraded" | "unavailable" | "refused" | "skipped";
|
|
71
71
|
/** What the sync said went wrong, empty when it had nothing to say. */
|
|
72
72
|
detail: string;
|
|
73
|
+
/**
|
|
74
|
+
* What the sync saw for each `dist/` file the publish named required: the
|
|
75
|
+
* hash of the bytes on the Computer, and whether the store ended up holding
|
|
76
|
+
* them. It is the evidence behind the publish's own failure sentence — a
|
|
77
|
+
* build output that demonstrably exists is never answered with "run `applet
|
|
78
|
+
* build` first" (production, 2026-09-04).
|
|
79
|
+
*/
|
|
80
|
+
required?: readonly {
|
|
81
|
+
/** The source-root-relative path, `<appletId>/dist/…`. */
|
|
82
|
+
path: string;
|
|
83
|
+
contentHash?: string;
|
|
84
|
+
durable: boolean;
|
|
85
|
+
}[];
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/** The three files `applet build` writes, read from the durable root. */
|
|
@@ -148,8 +161,13 @@ export interface AppletInstanceBindingV1 {
|
|
|
148
161
|
diagnostics: string[];
|
|
149
162
|
}
|
|
150
163
|
>;
|
|
164
|
+
/**
|
|
165
|
+
* `generationId` is the Applet generation the calling Turn pinned. The
|
|
166
|
+
* instance runs that generation or refuses the call (ADR 0038).
|
|
167
|
+
*/
|
|
151
168
|
invokeTool(input: {
|
|
152
169
|
appletId: string;
|
|
170
|
+
generationId: string;
|
|
153
171
|
tool: string;
|
|
154
172
|
input: unknown;
|
|
155
173
|
}): Promise<{ status: "ok" | "error"; content: string }>;
|
|
@@ -249,7 +267,11 @@ export function createAppletInstanceBindingV1(
|
|
|
249
267
|
const answer = JSON.parse(
|
|
250
268
|
JSON.stringify(
|
|
251
269
|
await rpc.invokeTool(
|
|
252
|
-
envelope({
|
|
270
|
+
envelope({
|
|
271
|
+
generationId: input.generationId,
|
|
272
|
+
tool: input.tool,
|
|
273
|
+
toolInput: input.input ?? null,
|
|
274
|
+
}),
|
|
253
275
|
),
|
|
254
276
|
),
|
|
255
277
|
) as { status?: unknown; content?: unknown };
|
|
@@ -618,6 +640,18 @@ export function createAppletCapabilityHostV1(
|
|
|
618
640
|
appletDistPathV1(options.userId, appletId, file),
|
|
619
641
|
);
|
|
620
642
|
if (outcome.status !== "ok") {
|
|
643
|
+
// Verify before reporting. The sync was asked for these exact paths, so
|
|
644
|
+
// it can say whether the Computer held the file and whether the store
|
|
645
|
+
// took it. A build output that is on disk is never answered with "run
|
|
646
|
+
// `applet build` first": that sends a Bot round a loop that cannot end.
|
|
647
|
+
const seen = synced?.required?.find(
|
|
648
|
+
(entry) => entry.path === `${appletId}/${file}`,
|
|
649
|
+
);
|
|
650
|
+
if (seen?.contentHash !== undefined) {
|
|
651
|
+
return {
|
|
652
|
+
failure: `"${file}" is on the Computer (sha256 ${seen.contentHash.slice(0, 12)}) but the Workspace answered ${outcome.status}: the publish sync reported ${synced?.status ?? "nothing"} and did not carry it${synced?.detail ? ` — ${synced.detail}` : ""}`,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
621
655
|
// A pull that did not finish is the likelier explanation than an
|
|
622
656
|
// unbuilt Applet, and telling the Bot to build again would send it
|
|
623
657
|
// round a loop that cannot end.
|
|
@@ -1,13 +1,26 @@
|
|
|
1
|
+
// Two things this seam owns, tested together because they share a Composition.
|
|
2
|
+
//
|
|
3
|
+
// First: which built-in members a Bot follows across a deployment.
|
|
4
|
+
//
|
|
5
|
+
// Second, the Applet half. An Applet member contributes no module and nothing
|
|
6
|
+
// to health-check, so the only thing this seam does with it is register its
|
|
7
|
+
// tools. What matters is *which* generation those tools reach: the description
|
|
8
|
+
// handed to the model names the pinned generation, and ADR 0038 requires the
|
|
9
|
+
// call to name it too, so the Applet Durable Object executes that generation or
|
|
10
|
+
// refuses. A DTO that carried only the Applet id let a publish landing mid-Turn
|
|
11
|
+
// run new code behind the schema and provenance the model was shown.
|
|
1
12
|
import { describe, expect, test } from "bun:test";
|
|
2
13
|
import type { ApplicationPlan } from "@frockbot/kernel-composition/compiler";
|
|
3
14
|
import {
|
|
4
15
|
compositionArtifactSetHashV1,
|
|
5
16
|
decodeCompositionGenerationV1,
|
|
17
|
+
type CompositionAppletMemberV1,
|
|
6
18
|
type CompositionGenerationV1,
|
|
7
19
|
type CompositionMemberV1,
|
|
8
20
|
} from "@frockbot/kernel-composition/generation";
|
|
9
21
|
import {
|
|
10
22
|
bootstrapCompositionGeneration,
|
|
23
|
+
createShellCompositionHost,
|
|
11
24
|
resolveDeploymentCompositionV1,
|
|
12
25
|
DEPLOYMENT_FOLLOW_SUMMARY_V1,
|
|
13
26
|
} from "./backend-composition.js";
|
|
@@ -219,3 +232,114 @@ describe("a Bot's built-in members follow the deployment", () => {
|
|
|
219
232
|
).toBeUndefined();
|
|
220
233
|
});
|
|
221
234
|
});
|
|
235
|
+
|
|
236
|
+
const USER = "user-42";
|
|
237
|
+
const APPLET = `${USER}.${"a".repeat(32)}`;
|
|
238
|
+
|
|
239
|
+
const APPLET_MEMBER: CompositionAppletMemberV1 = {
|
|
240
|
+
kind: "applet",
|
|
241
|
+
appletId: APPLET,
|
|
242
|
+
generationId: "2026-09-05T00:00:00.000Z:A",
|
|
243
|
+
tools: [
|
|
244
|
+
{
|
|
245
|
+
name: "add_todo",
|
|
246
|
+
description: "Add a todo",
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: "object",
|
|
249
|
+
properties: { title: { type: "string" } },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
provenance: {
|
|
254
|
+
kind: "user",
|
|
255
|
+
packageId: APPLET,
|
|
256
|
+
version: "2026-09-05T00:00:00.000Z:A",
|
|
257
|
+
userId: USER,
|
|
258
|
+
authoredAt: "2026-09-05T00:00:00.000Z",
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
async function generationWithApplet(): Promise<CompositionGenerationV1> {
|
|
263
|
+
const members = [
|
|
264
|
+
{
|
|
265
|
+
packageId: "shell",
|
|
266
|
+
specifier: "@frockbot/plugin-shell",
|
|
267
|
+
version: "1.0.0",
|
|
268
|
+
manifestHash: "c".repeat(64),
|
|
269
|
+
provenance: {
|
|
270
|
+
kind: "first-party" as const,
|
|
271
|
+
packageId: "shell",
|
|
272
|
+
version: "1.0.0",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
const applets = [APPLET_MEMBER];
|
|
277
|
+
const artifactSetHash = await compositionArtifactSetHashV1(members, applets);
|
|
278
|
+
return decodeCompositionGenerationV1({
|
|
279
|
+
schemaVersion: 1,
|
|
280
|
+
generationId: `2026-09-05T00:00:00.000Z:${artifactSetHash.slice(0, 16)}`,
|
|
281
|
+
artifactSetHash,
|
|
282
|
+
createdAt: "2026-09-05T00:00:00.000Z",
|
|
283
|
+
origin: { kind: "bootstrap" },
|
|
284
|
+
members,
|
|
285
|
+
applets,
|
|
286
|
+
status: "active",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
describe("Applet tools mounted into a Turn's Composition", () => {
|
|
291
|
+
test("the call carries the generation the Turn pinned, not just the Applet id", async () => {
|
|
292
|
+
const calls: Array<{ appletId: string; generationId: string }> = [];
|
|
293
|
+
const generation = await generationWithApplet();
|
|
294
|
+
const { signal } = new AbortController();
|
|
295
|
+
const mounted = await createShellCompositionHost({
|
|
296
|
+
botId: "bot-1",
|
|
297
|
+
sessionId: `${USER}:bot-1`,
|
|
298
|
+
sessionEvents: [],
|
|
299
|
+
admitEffect: () => Promise.resolve(true),
|
|
300
|
+
applets: {
|
|
301
|
+
invokeTool: (request) => {
|
|
302
|
+
calls.push({
|
|
303
|
+
appletId: request.appletId,
|
|
304
|
+
generationId: request.generationId,
|
|
305
|
+
});
|
|
306
|
+
return Promise.resolve({ status: "ok" as const, content: "added" });
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
}).mount(generation, signal);
|
|
310
|
+
try {
|
|
311
|
+
await mounted.verify(signal);
|
|
312
|
+
const schema = mounted.root.tools
|
|
313
|
+
.schemas({ turnType: "chat" })
|
|
314
|
+
.find((entry) => entry.name === "add_todo");
|
|
315
|
+
expect(schema).toBeDefined();
|
|
316
|
+
// The pin the model is shown, and the pin the call carries, are one
|
|
317
|
+
// string. If they ever diverge the Turn stops being reconstructable.
|
|
318
|
+
expect(schema?.description).toContain(APPLET_MEMBER.generationId);
|
|
319
|
+
|
|
320
|
+
const call = { id: "call-1", name: "add_todo", input: { title: "milk" } };
|
|
321
|
+
const context = {
|
|
322
|
+
botId: "bot-1",
|
|
323
|
+
agentId: "bot-1",
|
|
324
|
+
sessionId: `${USER}:bot-1`,
|
|
325
|
+
compositionGenerationId: generation.generationId,
|
|
326
|
+
effectId: "effect-1",
|
|
327
|
+
toolCall: call,
|
|
328
|
+
turnType: "chat" as const,
|
|
329
|
+
signal,
|
|
330
|
+
};
|
|
331
|
+
const prepared = await mounted.root.tools.prepare(call, context);
|
|
332
|
+
expect(prepared.kind).toBe("ready");
|
|
333
|
+
const outcome = await mounted.root.tools.executePrepared(
|
|
334
|
+
prepared as Extract<typeof prepared, { kind: "ready" }>,
|
|
335
|
+
context,
|
|
336
|
+
);
|
|
337
|
+
expect(outcome).toMatchObject({ isError: false });
|
|
338
|
+
expect(calls).toEqual([
|
|
339
|
+
{ appletId: APPLET, generationId: APPLET_MEMBER.generationId },
|
|
340
|
+
]);
|
|
341
|
+
} finally {
|
|
342
|
+
await mounted.dispose();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -185,10 +185,16 @@ export interface ShellIsolateMountOptions {
|
|
|
185
185
|
* (`docs/research/spike-applet-facets.md` §8). Absent when the Bot Durable
|
|
186
186
|
* Object has no Applet binding: an Applet member's tools are then simply not
|
|
187
187
|
* registered, exactly as an isolate member fails without a loader.
|
|
188
|
+
*
|
|
189
|
+
* `generationId` is the Applet generation this Turn's Composition pinned, and
|
|
190
|
+
* it is not decoration: the Applet Durable Object runs that generation or
|
|
191
|
+
* refuses the call (ADR 0038). Without it a publish landing mid-Turn would
|
|
192
|
+
* execute new code behind the schema and provenance the model was shown.
|
|
188
193
|
*/
|
|
189
194
|
export interface ShellAppletMountOptions {
|
|
190
195
|
invokeTool(request: {
|
|
191
196
|
appletId: string;
|
|
197
|
+
generationId: string;
|
|
192
198
|
tool: string;
|
|
193
199
|
input: unknown;
|
|
194
200
|
}): Promise<{ status: "ok" | "error"; content: string }>;
|
|
@@ -385,6 +391,9 @@ export function createShellCompositionHost(
|
|
|
385
391
|
execute: async (input) => {
|
|
386
392
|
const outcome = await routing.invokeTool({
|
|
387
393
|
appletId: applet.appletId,
|
|
394
|
+
// The pin the description above advertises, carried into the
|
|
395
|
+
// call so the instance executes it or refuses.
|
|
396
|
+
generationId: applet.generationId,
|
|
388
397
|
tool: tool.name,
|
|
389
398
|
input: input ?? null,
|
|
390
399
|
});
|
package/src/backend.ts
CHANGED
|
@@ -2521,7 +2521,11 @@ export class ShellBotBackendContribution {
|
|
|
2521
2521
|
),
|
|
2522
2522
|
signal: active.signal,
|
|
2523
2523
|
});
|
|
2524
|
-
return {
|
|
2524
|
+
return {
|
|
2525
|
+
status: summary.status,
|
|
2526
|
+
detail: summary.detail,
|
|
2527
|
+
...(summary.required ? { required: summary.required } : {}),
|
|
2528
|
+
};
|
|
2525
2529
|
}
|
|
2526
2530
|
: undefined,
|
|
2527
2531
|
composition: {
|