@frockbot/plugin-shell 0.3.1 → 0.3.3
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 -29
- package/src/agent.test.ts +15 -3
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +66 -27
- package/src/backend-completion.ts +4 -2
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend-recovery.ts +20 -12
- package/src/backend-runner-iframe.test.ts +10 -1
- package/src/backend-runner.ts +9 -2
- package/src/backend-stop.test.ts +6 -6
- package/src/backend-supersede.test.ts +377 -0
- package/src/backend.ts +567 -13
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +195 -21
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +221 -7
- package/src/client/index.ts +398 -6
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +118 -1
- package/src/composition-views.ts +31 -6
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +70 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -0
|
@@ -15,13 +15,19 @@ describe("Package iframe server admission", () => {
|
|
|
15
15
|
packageId: "weather-page",
|
|
16
16
|
displayName: "Weather page",
|
|
17
17
|
provenance: "Bot-authored",
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
pages: [
|
|
19
|
+
{
|
|
20
|
+
id: "main",
|
|
21
|
+
artifact: {
|
|
22
|
+
contentHash: "a".repeat(64),
|
|
23
|
+
size: 123,
|
|
24
|
+
mediaType: "text/html",
|
|
25
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
26
|
+
},
|
|
27
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
entries: [],
|
|
25
31
|
declaredTools: ["weather_lookup"],
|
|
26
32
|
},
|
|
27
33
|
],
|
|
@@ -56,7 +62,7 @@ describe("Package iframe server admission", () => {
|
|
|
56
62
|
bundlerVersion: "frockbot-inline-html@1",
|
|
57
63
|
};
|
|
58
64
|
const manifest: FrockBotManifest = {
|
|
59
|
-
schemaVersion:
|
|
65
|
+
schemaVersion: 5,
|
|
60
66
|
id: "weather-page",
|
|
61
67
|
displayName: "Weather page",
|
|
62
68
|
version: "0.0.1",
|
|
@@ -66,8 +72,13 @@ describe("Package iframe server admission", () => {
|
|
|
66
72
|
runtime: { entry: "./package.js", host: "bot-isolate" },
|
|
67
73
|
client: {
|
|
68
74
|
kind: "iframe",
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
pages: [
|
|
76
|
+
{
|
|
77
|
+
id: "main",
|
|
78
|
+
artifact: uiArtifact,
|
|
79
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
71
82
|
},
|
|
72
83
|
},
|
|
73
84
|
tools: [
|
|
@@ -122,8 +133,14 @@ describe("Package iframe server admission", () => {
|
|
|
122
133
|
packageId: "weather-page",
|
|
123
134
|
displayName: "Weather page",
|
|
124
135
|
provenance: "User-installed",
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
pages: [
|
|
137
|
+
{
|
|
138
|
+
id: "main",
|
|
139
|
+
artifact: uiArtifact,
|
|
140
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
entries: [],
|
|
127
144
|
declaredTools: ["weather_lookup"],
|
|
128
145
|
},
|
|
129
146
|
]);
|
package/src/backend-isolate.ts
CHANGED
|
@@ -251,6 +251,16 @@ export function isolateModelEventStreamV1(
|
|
|
251
251
|
* identity. User, Bot, the resolved model binding, and the pinned Composition
|
|
252
252
|
* generation complete the digest. Package id is deliberately absent: two
|
|
253
253
|
* Packages of one Bot receive the same authority projection.
|
|
254
|
+
*
|
|
255
|
+
* `runId` is here because `CAPABILITIES` is one of those bindings and it is
|
|
256
|
+
* scoped to one Turn: the stub carries that Turn's run, session, and turn id,
|
|
257
|
+
* and the Bot Durable Object refuses a capability call whose scope is not the
|
|
258
|
+
* Turn it is currently running. The loader hands back the *cached* worker for a
|
|
259
|
+
* repeated id, env and all, so leaving the Turn out of the digest gave a second
|
|
260
|
+
* Turn on one Composition generation an isolate holding the first Turn's stub —
|
|
261
|
+
* every `ctx.memory`, `ctx.workspace`, and `ctx.applets` call in it answered
|
|
262
|
+
* "the Package is not running in this Bot's active Composition". The digest is
|
|
263
|
+
* a statement about what is bound; a per-Turn binding belongs in it.
|
|
254
264
|
*/
|
|
255
265
|
export async function isolateBindingDigestV1(input: {
|
|
256
266
|
userId: string;
|
|
@@ -261,6 +271,8 @@ export async function isolateBindingDigestV1(input: {
|
|
|
261
271
|
>[];
|
|
262
272
|
model?: IsolateModelBindingV1;
|
|
263
273
|
compositionGenerationId: string;
|
|
274
|
+
/** The Turn whose `CAPABILITIES` stub this isolate's env carries. */
|
|
275
|
+
runId?: string;
|
|
264
276
|
}): Promise<string> {
|
|
265
277
|
const connections = [...input.connections]
|
|
266
278
|
.map(({ connectionId, generation }) => ({ connectionId, generation }))
|
|
@@ -270,6 +282,7 @@ export async function isolateBindingDigestV1(input: {
|
|
|
270
282
|
userId: input.userId,
|
|
271
283
|
botId: input.botId,
|
|
272
284
|
compositionGenerationId: input.compositionGenerationId,
|
|
285
|
+
runId: input.runId ?? null,
|
|
273
286
|
connections,
|
|
274
287
|
model: input.model ?? null,
|
|
275
288
|
}),
|
|
@@ -290,17 +303,30 @@ async function sha256Hex(value: string): Promise<string> {
|
|
|
290
303
|
* Reads a Bot Package artifact from object storage and verifies its content
|
|
291
304
|
* address before a byte of it becomes code. Artifacts are immutable content,
|
|
292
305
|
* not state; the hash is the only thing that makes them safe to mount.
|
|
306
|
+
*
|
|
307
|
+
* `bundled` is a second *place* to find the same immutable bytes, not a second
|
|
308
|
+
* kind of thing. A first-party Package that ships as an artifact-backed member
|
|
309
|
+
* (ADR 0022 decision 8) is built at build time and travels inside this bundle,
|
|
310
|
+
* so its bytes are already here and object storage never has to be seeded for
|
|
311
|
+
* a deploy to be correct. Object storage still wins when it holds the object,
|
|
312
|
+
* the digest is still verified either way, and nothing is ever *built* here —
|
|
313
|
+
* which is what "Composition consumes immutable content-addressed artifacts and
|
|
314
|
+
* never builds them" asks of this seam.
|
|
293
315
|
*/
|
|
294
316
|
export function createR2PackageArtifactStore(
|
|
295
317
|
bucket: R2Bucket,
|
|
318
|
+
bundled?: ReadonlyMap<string, string>,
|
|
296
319
|
): BotIsolateArtifactStore {
|
|
297
320
|
return {
|
|
298
321
|
async loadPackageArtifact(contentHash: string): Promise<string> {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
322
|
+
const key = `packages/${contentHash}.mjs`;
|
|
323
|
+
const object = await bucket.get(key);
|
|
324
|
+
const module = object
|
|
325
|
+
? await object.text()
|
|
326
|
+
: (bundled?.get(key) ??
|
|
327
|
+
(() => {
|
|
328
|
+
throw new Error(`package artifact "${contentHash}" is missing`);
|
|
329
|
+
})());
|
|
304
330
|
if ((await sha256Hex(module)) !== contentHash) {
|
|
305
331
|
throw new Error(
|
|
306
332
|
`package artifact "${contentHash}" failed hash verification`,
|
|
@@ -28,7 +28,7 @@ const UI_SIZE = 42;
|
|
|
28
28
|
const CREATED_AT = "2026-09-02T00:00:00.000Z";
|
|
29
29
|
|
|
30
30
|
const manifest = {
|
|
31
|
-
schemaVersion:
|
|
31
|
+
schemaVersion: 5 as const,
|
|
32
32
|
id: "parcel-tracking",
|
|
33
33
|
displayName: "Parcel tracking",
|
|
34
34
|
version: "0.0.1",
|
|
@@ -38,13 +38,18 @@ const manifest = {
|
|
|
38
38
|
runtime: { entry: "./package.js", host: "bot-isolate" as const },
|
|
39
39
|
client: {
|
|
40
40
|
kind: "iframe" as const,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
pages: [
|
|
42
|
+
{
|
|
43
|
+
id: "main",
|
|
44
|
+
artifact: {
|
|
45
|
+
contentHash: UI_CONTENT_HASH,
|
|
46
|
+
size: UI_SIZE,
|
|
47
|
+
mediaType: "text/html" as const,
|
|
48
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
49
|
+
},
|
|
50
|
+
mounts: [{ slot: "frockbot.tool-result:track_parcel" }],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
48
53
|
},
|
|
49
54
|
},
|
|
50
55
|
configuration: {
|
|
@@ -259,13 +259,15 @@ export function createPackageCatalogHost(
|
|
|
259
259
|
}
|
|
260
260
|
const client = entry.bundle.manifest.contributions.client;
|
|
261
261
|
if (client && isClientIframeContribution(client)) {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (!uiArtifact || uiArtifact.size !== client.artifact.size) {
|
|
266
|
-
throw new Error(
|
|
267
|
-
`Catalog iframe artifact "${client.artifact.contentHash}" is absent or has the wrong size`,
|
|
262
|
+
for (const page of client.pages) {
|
|
263
|
+
const uiArtifact = await options.catalog.headUiArtifact(
|
|
264
|
+
page.artifact.contentHash,
|
|
268
265
|
);
|
|
266
|
+
if (!uiArtifact || uiArtifact.size !== page.artifact.size) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Catalog iframe artifact "${page.artifact.contentHash}" is absent or has the wrong size`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
269
271
|
}
|
|
270
272
|
}
|
|
271
273
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
initializeBotSettingsV1,
|
|
9
9
|
type UserSettingsViewV1,
|
|
10
10
|
} from "@frockbot/configuration-core";
|
|
11
|
+
import { compileFoundationApplication } from "@frockbot/application-foundation/runtime";
|
|
11
12
|
import { createShellBotBackendContribution } from "./backend.js";
|
|
12
13
|
import {
|
|
13
14
|
botTurnCommandFingerprintV1,
|
|
@@ -85,6 +86,27 @@ class MemoryStorage {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/*
|
|
90
|
+
* The application as a host with no Worker Loader can mount it.
|
|
91
|
+
*
|
|
92
|
+
* This suite runs under `bun test`, where there is no `BOT_PACKAGES` binding
|
|
93
|
+
* and no `BotCapabilities` loopback, so an artifact-backed member — the
|
|
94
|
+
* Applets Package, ADR 0022 decision 8 — has nowhere to load from and the
|
|
95
|
+
* Composition fails verification closed, which is correct and is not what
|
|
96
|
+
* these tests are about. Dropping those members is the honest way to say
|
|
97
|
+
* "this host cannot run isolate code"; it is not a switch over any Package's
|
|
98
|
+
* identity, and workerd's suites mount the real thing.
|
|
99
|
+
*/
|
|
100
|
+
async function compileWithoutIsolateMembers(): ReturnType<
|
|
101
|
+
typeof compileFoundationApplication
|
|
102
|
+
> {
|
|
103
|
+
const application = await compileFoundationApplication();
|
|
104
|
+
return {
|
|
105
|
+
...application,
|
|
106
|
+
packages: application.packages.filter((pkg) => pkg.artifact === undefined),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
describe("Bot recovery", () => {
|
|
89
111
|
test("executes and reconstructs an Ollama-bound Bot without Foundation fallback", async () => {
|
|
90
112
|
const storage = new MemoryStorage();
|
|
@@ -216,6 +238,7 @@ describe("Bot recovery", () => {
|
|
|
216
238
|
typeof createShellBotBackendContribution
|
|
217
239
|
>[0]["env"],
|
|
218
240
|
outboundFetch,
|
|
241
|
+
compileApplication: compileWithoutIsolateMembers,
|
|
219
242
|
});
|
|
220
243
|
|
|
221
244
|
const configured = host();
|
package/src/backend-recovery.ts
CHANGED
|
@@ -22,24 +22,32 @@ export {
|
|
|
22
22
|
type ModelRequestJournalState,
|
|
23
23
|
} from "@frockbot/kernel-do";
|
|
24
24
|
|
|
25
|
-
/** How
|
|
26
|
-
export type
|
|
25
|
+
/** How an interrupted run's unresolved effects settle. */
|
|
26
|
+
export type InterruptedRunRecoveryPlanV1 =
|
|
27
27
|
{ kind: "cancel"; events: SessionEvent[] } | { kind: "reconcile" };
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Classifies the unresolved effects of a
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* Classifies the unresolved effects of a run that has been fenced — by a
|
|
31
|
+
* durable Stop, or by a later user message that superseded it — from their
|
|
32
|
+
* durable admission records. With no compatibility data, a missing admission
|
|
33
|
+
* is the same definitive no-start outcome as `fenced`; only explicit
|
|
34
|
+
* `admitted` remains uncertain.
|
|
35
|
+
*
|
|
36
|
+
* The two intents share this function because they share the whole question:
|
|
37
|
+
* an effect that may already have run is never assumed not to have, whichever
|
|
38
|
+
* intent stopped the Turn.
|
|
34
39
|
*/
|
|
35
|
-
export function
|
|
40
|
+
export function planInterruptedRunRecoveryV1(
|
|
36
41
|
run: StoredRun,
|
|
37
42
|
latest: readonly SessionEvent[],
|
|
38
|
-
):
|
|
43
|
+
): InterruptedRunRecoveryPlanV1 {
|
|
39
44
|
requireStoredRunV1(run);
|
|
40
|
-
if (!run.stopRequestedAt) {
|
|
41
|
-
throw new Error(
|
|
45
|
+
if (!run.stopRequestedAt && !run.supersededAt) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`run "${run.runId}" has no durable stop or supersede intent`,
|
|
48
|
+
);
|
|
42
49
|
}
|
|
50
|
+
const fenceReason = run.stopRequestedAt ? "Durable Stop" : "A supersede";
|
|
43
51
|
const admissionFor = (kind: "model" | "tool", effectId: string) => {
|
|
44
52
|
const admission = run.effectAdmissions.find(
|
|
45
53
|
(candidate) => candidate.effectId === effectId,
|
|
@@ -75,7 +83,7 @@ export function planStoppedRunRecovery(
|
|
|
75
83
|
turn: model.request.turn,
|
|
76
84
|
step: model.request.step,
|
|
77
85
|
requestId: model.request.request.requestId,
|
|
78
|
-
reason:
|
|
86
|
+
reason: `${fenceReason} fenced provider execution before admission`,
|
|
79
87
|
});
|
|
80
88
|
}
|
|
81
89
|
for (const entry of openTools) {
|
|
@@ -86,7 +94,7 @@ export function planStoppedRunRecovery(
|
|
|
86
94
|
step: intent.step,
|
|
87
95
|
occurrenceId: intent.occurrenceId,
|
|
88
96
|
name: intent.name,
|
|
89
|
-
content:
|
|
97
|
+
content: `${fenceReason} fenced tool execution before admission.`,
|
|
90
98
|
isError: true,
|
|
91
99
|
status: "interrupted",
|
|
92
100
|
});
|
|
@@ -12,8 +12,14 @@ describe("Package iframe direct tool Turn", () => {
|
|
|
12
12
|
await root.plugin(ToolRegistry);
|
|
13
13
|
const session = root.sessions.create("user:bot");
|
|
14
14
|
let calls = 0;
|
|
15
|
+
root.tools.registerNamespace({
|
|
16
|
+
name: "weather-page",
|
|
17
|
+
external: true,
|
|
18
|
+
status: "ready",
|
|
19
|
+
});
|
|
15
20
|
root.tools.register({
|
|
16
21
|
name: "weather_lookup",
|
|
22
|
+
namespace: "weather-page",
|
|
17
23
|
description: "Weather",
|
|
18
24
|
inputSchema: {},
|
|
19
25
|
idempotent: true,
|
|
@@ -69,6 +75,9 @@ describe("Package iframe direct tool Turn", () => {
|
|
|
69
75
|
);
|
|
70
76
|
expect(
|
|
71
77
|
result.events.find((event) => event.type === "tool/result"),
|
|
72
|
-
).toMatchObject({
|
|
78
|
+
).toMatchObject({
|
|
79
|
+
name: "call_dynamic_tool",
|
|
80
|
+
content: '{"temperature":21}',
|
|
81
|
+
});
|
|
73
82
|
});
|
|
74
83
|
});
|
package/src/backend-runner.ts
CHANGED
|
@@ -177,8 +177,15 @@ export async function executeDirectToolTurn(
|
|
|
177
177
|
const session = composition.runtime.agent.agent.session;
|
|
178
178
|
const call = {
|
|
179
179
|
id: command.runId,
|
|
180
|
-
name:
|
|
181
|
-
input:
|
|
180
|
+
name: "call_dynamic_tool",
|
|
181
|
+
input: {
|
|
182
|
+
namespace: command.directTool.packageId,
|
|
183
|
+
toolName: command.directTool.name,
|
|
184
|
+
arguments: command.directTool.input,
|
|
185
|
+
mcpDetails: {
|
|
186
|
+
description: `The User invoked ${command.directTool.name} from the Package UI.`,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
182
189
|
};
|
|
183
190
|
try {
|
|
184
191
|
let turnStart = [...session.events].findLast(
|
package/src/backend-stop.test.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
botTurnCommandFingerprintV1,
|
|
13
13
|
type StoredRun,
|
|
14
14
|
} from "./backend-contracts.js";
|
|
15
|
-
import {
|
|
15
|
+
import { planInterruptedRunRecoveryV1 } from "./backend-recovery.js";
|
|
16
16
|
|
|
17
17
|
class MemoryStorage {
|
|
18
18
|
readonly values = new Map<string, unknown>();
|
|
@@ -298,7 +298,7 @@ describe("stopped run recovery", () => {
|
|
|
298
298
|
],
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
-
const plan =
|
|
301
|
+
const plan = planInterruptedRunRecoveryV1(run, run.events);
|
|
302
302
|
|
|
303
303
|
expect(plan.kind).toBe("cancel");
|
|
304
304
|
if (plan.kind !== "cancel") throw new Error("expected cancellation");
|
|
@@ -323,7 +323,7 @@ describe("stopped run recovery", () => {
|
|
|
323
323
|
],
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
-
const plan =
|
|
326
|
+
const plan = planInterruptedRunRecoveryV1(run, run.events);
|
|
327
327
|
|
|
328
328
|
expect(plan.kind).toBe("cancel");
|
|
329
329
|
if (plan.kind !== "cancel") throw new Error("expected cancellation");
|
|
@@ -341,7 +341,7 @@ describe("stopped run recovery", () => {
|
|
|
341
341
|
],
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
-
expect(
|
|
344
|
+
expect(planInterruptedRunRecoveryV1(run, run.events)).toEqual({
|
|
345
345
|
kind: "reconcile",
|
|
346
346
|
});
|
|
347
347
|
});
|
|
@@ -349,8 +349,8 @@ describe("stopped run recovery", () => {
|
|
|
349
349
|
test("refuses to plan recovery for a run carrying no durable Stop intent", () => {
|
|
350
350
|
const run = storedRun({ events: modelIntentEvents() });
|
|
351
351
|
|
|
352
|
-
expect(() =>
|
|
353
|
-
`run "${turn.runId}" has no durable stop intent`,
|
|
352
|
+
expect(() => planInterruptedRunRecoveryV1(run, run.events)).toThrow(
|
|
353
|
+
`run "${turn.runId}" has no durable stop or supersede intent`,
|
|
354
354
|
);
|
|
355
355
|
});
|
|
356
356
|
});
|