@frockbot/plugin-shell 0.3.9 → 0.3.11
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 +33 -31
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +42 -1
- package/src/backend-configuration.test.ts +10 -2
- package/src/backend-package-catalog.ts +98 -21
- package/src/backend.ts +99 -13
- package/src/client/FrockBotApp.vue +52 -111
- package/src/client/activity-ring.test.ts +89 -0
- package/src/client/activity-ring.ts +94 -0
- package/src/client/index.ts +1 -0
- package/src/client/model-presentation.test.ts +14 -0
- package/src/client/model-presentation.ts +6 -0
- package/src/client/styles.css +39 -93
- package/src/composition-manifest.test.ts +126 -0
- package/src/composition-manifest.ts +55 -0
- package/src/notification-id.test.ts +93 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.ts +13 -2
- package/src/settings-links.ts +6 -0
- package/src/unread.ts +23 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Every Bot-authored Package failed to mount with `package "aud-usd" stored
|
|
2
|
+
// manifest failed hash verification`, deterministically, on byte-identical
|
|
3
|
+
// source. The mount hashed a *decoded* manifest against a hash taken over the
|
|
4
|
+
// *raw* one, so journey 4 step 2 — "use the tool you just built" — was
|
|
5
|
+
// impossible for anybody. First-party members mounted fine, because their
|
|
6
|
+
// reader already returned the raw document.
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { authoredManifestV1 } from "@frockbot/plugin-authoring/shared";
|
|
9
|
+
import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
|
|
10
|
+
import type { CompositionMemberV1 } from "@frockbot/kernel-composition/generation";
|
|
11
|
+
import { botIsolatePackageDescriptorV1 } from "@frockbot/kernel-composition/isolate";
|
|
12
|
+
import { canonicalJson, sha256 } from "@frockbot/kernel-composition/compiler";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type CompositionManifestSourcesV1,
|
|
16
|
+
compositionMemberManifestDocumentV1,
|
|
17
|
+
compositionMemberManifestV1,
|
|
18
|
+
} from "./composition-manifest.js";
|
|
19
|
+
|
|
20
|
+
/** What `package_author` writes, exactly as `backend-authoring.ts` builds it. */
|
|
21
|
+
function authoredManifest(): unknown {
|
|
22
|
+
return authoredManifestV1({
|
|
23
|
+
packageId: "aud-usd",
|
|
24
|
+
displayName: "AUD/USD",
|
|
25
|
+
version: "0.0.1",
|
|
26
|
+
tools: [
|
|
27
|
+
{
|
|
28
|
+
name: "aud_usd_rate",
|
|
29
|
+
description: "The current AUD/USD rate.",
|
|
30
|
+
inputSchema: { type: "object", properties: {} },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The durable store, as `authorship:manifest:<hash>` holds it. */
|
|
37
|
+
async function storedMember(): Promise<{
|
|
38
|
+
member: CompositionMemberV1;
|
|
39
|
+
manifest: unknown;
|
|
40
|
+
sources: CompositionManifestSourcesV1;
|
|
41
|
+
}> {
|
|
42
|
+
const manifest = authoredManifest();
|
|
43
|
+
// The one hash that matters: taken over the raw document at authoring time.
|
|
44
|
+
const manifestHash = await sha256(canonicalJson(manifest));
|
|
45
|
+
const member: CompositionMemberV1 = {
|
|
46
|
+
packageId: "aud-usd",
|
|
47
|
+
specifier: "bot:aud-usd",
|
|
48
|
+
version: "0.0.1",
|
|
49
|
+
manifestHash,
|
|
50
|
+
provenance: {
|
|
51
|
+
kind: "bot",
|
|
52
|
+
packageId: "aud-usd",
|
|
53
|
+
version: "0.0.1",
|
|
54
|
+
botId: "toolsmith",
|
|
55
|
+
sessionId: "user-1:toolsmith",
|
|
56
|
+
turnId: "turn-1",
|
|
57
|
+
runId: "run-1",
|
|
58
|
+
authoredAt: "2026-09-03T23:49:00.416Z",
|
|
59
|
+
},
|
|
60
|
+
artifact: {
|
|
61
|
+
contentHash: "b".repeat(64),
|
|
62
|
+
size: 128,
|
|
63
|
+
mediaType: "application/javascript",
|
|
64
|
+
bundlerVersion: "worker-bundler@0.2.3",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const sources: CompositionManifestSourcesV1 = {
|
|
68
|
+
stored: (hash) =>
|
|
69
|
+
Promise.resolve(hash === manifestHash ? { manifest } : undefined),
|
|
70
|
+
application: () => Promise.resolve(undefined),
|
|
71
|
+
};
|
|
72
|
+
return { member, manifest, sources };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("the manifest a mount is handed", () => {
|
|
76
|
+
test("hashes to what the Composition member recorded", async () => {
|
|
77
|
+
const { member, sources } = await storedMember();
|
|
78
|
+
|
|
79
|
+
const document = await compositionMemberManifestDocumentV1(member, sources);
|
|
80
|
+
const descriptor = await botIsolatePackageDescriptorV1(member, document);
|
|
81
|
+
|
|
82
|
+
expect(descriptor.manifest.id).toBe("aud-usd");
|
|
83
|
+
expect(descriptor.manifest.version).toBe("0.0.1");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("is the stored document, not a rebuild of it", async () => {
|
|
87
|
+
const { member, manifest, sources } = await storedMember();
|
|
88
|
+
|
|
89
|
+
const document = await compositionMemberManifestDocumentV1(member, sources);
|
|
90
|
+
|
|
91
|
+
expect(document).toBe(manifest);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("decoding first is what broke it, and still would", async () => {
|
|
95
|
+
// The guard this test exists for. `decodeFrockBotManifest` rebuilds the
|
|
96
|
+
// object — `decodeV5` always writes a `configuration` key — so the decoded
|
|
97
|
+
// manifest does not canonicalize back to the recorded hash. If the reader
|
|
98
|
+
// ever decodes again, the test above fails and this one says why.
|
|
99
|
+
const { member, manifest } = await storedMember();
|
|
100
|
+
const decoded = decodeFrockBotManifest(manifest);
|
|
101
|
+
|
|
102
|
+
expect(canonicalJson(decoded)).not.toBe(canonicalJson(manifest));
|
|
103
|
+
await expect(
|
|
104
|
+
botIsolatePackageDescriptorV1(member, decoded),
|
|
105
|
+
).rejects.toThrow("stored manifest failed hash verification");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the typed reader still answers the shape its callers want", async () => {
|
|
109
|
+
const { member, sources } = await storedMember();
|
|
110
|
+
|
|
111
|
+
const typed = await compositionMemberManifestV1(member, sources);
|
|
112
|
+
|
|
113
|
+
expect(typed?.id).toBe("aud-usd");
|
|
114
|
+
expect(typed?.version).toBe("0.0.1");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a member with no stored manifest falls back to the application's", async () => {
|
|
118
|
+
const { member, manifest } = await storedMember();
|
|
119
|
+
const document = await compositionMemberManifestDocumentV1(member, {
|
|
120
|
+
stored: () => Promise.resolve(undefined),
|
|
121
|
+
application: () => Promise.resolve(manifest),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(document).toBe(manifest);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FrockBotManifest,
|
|
3
|
+
decodeFrockBotManifest,
|
|
4
|
+
} from "@frockbot/kernel-composition";
|
|
5
|
+
import type { CompositionMemberV1 } from "@frockbot/kernel-composition/generation";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where a Composition member's manifest can be found. `stored` is
|
|
9
|
+
* `authorship:manifest:<hash>` — written by the authoring path and by a
|
|
10
|
+
* Catalog install, so it covers every member a Bot or its User put into the
|
|
11
|
+
* Composition. `application` is the compiled-in manifest of a first-party
|
|
12
|
+
* artifact-backed member (ADR 0022 decision 8), which came from neither and is
|
|
13
|
+
* already in this bundle. Two *places*, never two answers: the `manifestHash`
|
|
14
|
+
* decides in both.
|
|
15
|
+
*/
|
|
16
|
+
export interface CompositionManifestSourcesV1 {
|
|
17
|
+
stored(manifestHash: string): Promise<{ manifest: unknown } | undefined>;
|
|
18
|
+
application(member: CompositionMemberV1): Promise<unknown | undefined>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A member's manifest **as the stored document** — byte-for-byte what its
|
|
23
|
+
* `manifestHash` was taken over.
|
|
24
|
+
*
|
|
25
|
+
* This is the seam that broke every Bot-authored Package. Authoring hashes
|
|
26
|
+
* `canonicalJson(rawManifest)` and stores `rawManifest` verbatim; the mount
|
|
27
|
+
* re-hashes whatever it is handed and refuses a mismatch
|
|
28
|
+
* (`botIsolatePackageDescriptorV1`). Returning a *decoded* manifest here put a
|
|
29
|
+
* rebuilt object on the mount's side of that comparison — `decodeV5` always
|
|
30
|
+
* writes a `configuration` key, among other things — so the two hashes could
|
|
31
|
+
* never agree, and every authored Package failed with `stored manifest failed
|
|
32
|
+
* hash verification` while first-party members (which were already read raw)
|
|
33
|
+
* mounted fine.
|
|
34
|
+
*
|
|
35
|
+
* Callers that want the typed shape decode it themselves, downstream of the
|
|
36
|
+
* hash check.
|
|
37
|
+
*/
|
|
38
|
+
export async function compositionMemberManifestDocumentV1(
|
|
39
|
+
member: CompositionMemberV1,
|
|
40
|
+
sources: CompositionManifestSourcesV1,
|
|
41
|
+
): Promise<unknown | undefined> {
|
|
42
|
+
const stored = await sources.stored(member.manifestHash);
|
|
43
|
+
if (stored) return stored.manifest;
|
|
44
|
+
return await sources.application(member);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The same manifest as the typed shape, for readers that are not mounts. */
|
|
48
|
+
export async function compositionMemberManifestV1(
|
|
49
|
+
member: CompositionMemberV1,
|
|
50
|
+
sources: CompositionManifestSourcesV1,
|
|
51
|
+
): Promise<FrockBotManifest | undefined> {
|
|
52
|
+
const document = await compositionMemberManifestDocumentV1(member, sources);
|
|
53
|
+
if (document === undefined) return undefined;
|
|
54
|
+
return decodeFrockBotManifest(document);
|
|
55
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { notificationIdV1 } from "./notification-id.js";
|
|
4
|
+
import { decodeClientNotificationAcknowledgementCommandV1 } from "./run-protocol.js";
|
|
5
|
+
|
|
6
|
+
/** What the acknowledge endpoint does with an id a notification carried. */
|
|
7
|
+
function acknowledge(notificationId: string): string {
|
|
8
|
+
return decodeClientNotificationAcknowledgementCommandV1({
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
action: "acknowledge",
|
|
11
|
+
notificationId,
|
|
12
|
+
}).notificationId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("notificationIdV1", () => {
|
|
16
|
+
test("a Composition failure id round-trips through acknowledgement", () => {
|
|
17
|
+
// The shape that 400'd forever: a generation id is `<ISO instant>:<hash>`,
|
|
18
|
+
// and the acknowledge decoder admits no colons.
|
|
19
|
+
const id = notificationIdV1(
|
|
20
|
+
"composition-failure",
|
|
21
|
+
"2026-09-03T23:49:00.416Z:dc03a32d9b717619",
|
|
22
|
+
1,
|
|
23
|
+
);
|
|
24
|
+
expect(id).not.toContain(":");
|
|
25
|
+
expect(acknowledge(id)).toBe(id);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("every minted id shape acknowledges", () => {
|
|
29
|
+
const minted = [
|
|
30
|
+
notificationIdV1(
|
|
31
|
+
"package-connection-unavailable",
|
|
32
|
+
crypto.randomUUID(),
|
|
33
|
+
"aud-usd",
|
|
34
|
+
"gmail",
|
|
35
|
+
),
|
|
36
|
+
notificationIdV1("package", "aud-usd", "rate:moved"),
|
|
37
|
+
notificationIdV1("routine-failed", "2026-09-03T23:49:00.416Z:fire"),
|
|
38
|
+
notificationIdV1("task-settled", crypto.randomUUID()),
|
|
39
|
+
notificationIdV1("routine-wake", crypto.randomUUID()),
|
|
40
|
+
];
|
|
41
|
+
for (const id of minted) expect(acknowledge(id)).toBe(id);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the same parts mint the same id, so a retry is one intent", () => {
|
|
45
|
+
const parts = [
|
|
46
|
+
"composition-failure",
|
|
47
|
+
"2026-09-03T23:49:00.416Z:abc",
|
|
48
|
+
2,
|
|
49
|
+
] as const;
|
|
50
|
+
expect(notificationIdV1(...parts)).toBe(notificationIdV1(...parts));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("parts the sanitiser would flatten together stay distinct", () => {
|
|
54
|
+
// Well under the length ceiling, so nothing else separates them: `a:b` and
|
|
55
|
+
// `a-b` both sanitise to `a-b`, and one notification silently overwrote
|
|
56
|
+
// the other.
|
|
57
|
+
expect(notificationIdV1("package", "a:b")).not.toBe(
|
|
58
|
+
notificationIdV1("package", "a-b"),
|
|
59
|
+
);
|
|
60
|
+
expect(notificationIdV1("a", "b")).not.toBe(notificationIdV1("a-b"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("distinct parts stay distinct, even past the length ceiling", () => {
|
|
64
|
+
const long = "x".repeat(400);
|
|
65
|
+
const a = notificationIdV1("package", long, "one");
|
|
66
|
+
const b = notificationIdV1("package", long, "two");
|
|
67
|
+
expect(a).not.toBe(b);
|
|
68
|
+
expect(a.length).toBeLessThanOrEqual(128);
|
|
69
|
+
expect(acknowledge(a)).toBe(a);
|
|
70
|
+
expect(acknowledge(b)).toBe(b);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a legacy colon id already in the field can still be acknowledged", () => {
|
|
74
|
+
// Bots that failed a generation before this fix are holding notifications
|
|
75
|
+
// whose ids were interpolated by hand. If those stay unacknowledgeable the
|
|
76
|
+
// client retries them forever, so the decoder admits the older shape even
|
|
77
|
+
// though nothing mints it any more.
|
|
78
|
+
const legacy =
|
|
79
|
+
"composition-failure:2026-09-03T23:49:00.416Z:dc03a32d9b717619:1";
|
|
80
|
+
expect(acknowledge(legacy)).toBe(legacy);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("an id is still bounded, whatever shape it arrives in", () => {
|
|
84
|
+
expect(() => acknowledge("a".repeat(200))).toThrow();
|
|
85
|
+
expect(() => acknowledge("../../escape")).toThrow();
|
|
86
|
+
expect(() => acknowledge("")).toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("parts that sanitize to nothing still mint an acknowledgeable id", () => {
|
|
90
|
+
const id = notificationIdV1(":::", "::");
|
|
91
|
+
expect(acknowledge(id)).toBe(id);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
Binary file
|
package/src/run-protocol.ts
CHANGED
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
type SessionEvent,
|
|
6
6
|
type SkillRefV1,
|
|
7
7
|
} from "@frockbot/kernel-contracts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isPublicIdentifier,
|
|
10
|
+
isRpcIdentifier,
|
|
11
|
+
} from "@frockbot/configuration-core";
|
|
9
12
|
import { decodeRunCursorV1, RUN_CURSOR_PATTERN } from "./run-cursor.js";
|
|
10
13
|
export { decodeRunCursorV1, RUN_CURSOR_PATTERN };
|
|
11
14
|
import type {
|
|
@@ -1648,7 +1651,15 @@ export function decodeClientNotificationAcknowledgementCommandV1(
|
|
|
1648
1651
|
MAX_RUN_ID_LENGTH,
|
|
1649
1652
|
"notification acknowledgement command",
|
|
1650
1653
|
);
|
|
1651
|
-
|
|
1654
|
+
// `isRpcIdentifier`, not `isPublicIdentifier`: the same bounded alphabet
|
|
1655
|
+
// plus `:` and `@`. New ids are minted through `notificationIdV1` and carry
|
|
1656
|
+
// neither, but Bots in the field are already holding notifications whose ids
|
|
1657
|
+
// were interpolated by hand — `composition-failure:<generationId>:<attempt>`
|
|
1658
|
+
// and three more like it. Under the stricter pattern those could never be
|
|
1659
|
+
// acknowledged, so the client retried them forever and the Bot answered 400
|
|
1660
|
+
// on every poll for the rest of its life. Accepting them here is what lets
|
|
1661
|
+
// an already-wedged Bot recover without a storage sweep.
|
|
1662
|
+
if (!isRpcIdentifier(notificationId)) {
|
|
1652
1663
|
throw new Error(
|
|
1653
1664
|
"notification acknowledgement command.notificationId is invalid",
|
|
1654
1665
|
);
|
package/src/settings-links.ts
CHANGED
|
@@ -81,6 +81,12 @@ export const SETTINGS_ANCHORS_V1: readonly SettingsAnchorV1[] = [
|
|
|
81
81
|
label: "Label",
|
|
82
82
|
scope: "bot",
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
anchor: "bot-pinned",
|
|
86
|
+
surface: "bot-settings",
|
|
87
|
+
label: "Pinned",
|
|
88
|
+
scope: "bot",
|
|
89
|
+
},
|
|
84
90
|
{
|
|
85
91
|
anchor: "bot-description",
|
|
86
92
|
surface: "bot-settings",
|
package/src/unread.ts
CHANGED
|
@@ -389,6 +389,15 @@ export interface BotUnreadViewV1 {
|
|
|
389
389
|
lastViewedAt?: string;
|
|
390
390
|
/** Latest settled assistant/user line, projected for the sidebar only. */
|
|
391
391
|
lastMessage?: SidebarMessagePreviewV1;
|
|
392
|
+
/**
|
|
393
|
+
* Whether this Bot has a Turn running right now.
|
|
394
|
+
*
|
|
395
|
+
* The sidebar draws it as an activity ring on the row's avatar, so somebody
|
|
396
|
+
* reading one conversation can see another Bot still working rather than
|
|
397
|
+
* assuming it stalled. Optional: a view a client older than the Bot decodes,
|
|
398
|
+
* or one stored before this existed, simply draws no ring.
|
|
399
|
+
*/
|
|
400
|
+
working?: boolean;
|
|
392
401
|
}
|
|
393
402
|
|
|
394
403
|
export interface BotUnreadDirectoryViewV1 {
|
|
@@ -414,6 +423,8 @@ export function projectBotUnreadViewV1(
|
|
|
414
423
|
* so it counts here even though the firing that produced it does not.
|
|
415
424
|
*/
|
|
416
425
|
automationFailures = 0,
|
|
426
|
+
/** True while a Turn of this Bot's is running. Drawn as the row's ring. */
|
|
427
|
+
working = false,
|
|
417
428
|
): BotUnreadViewV1 {
|
|
418
429
|
const ceiling = state.lastActivityCursor;
|
|
419
430
|
let counted = Math.max(0, automationFailures);
|
|
@@ -449,6 +460,7 @@ export function projectBotUnreadViewV1(
|
|
|
449
460
|
? {}
|
|
450
461
|
: { lastViewedAt: state.lastViewedAt }),
|
|
451
462
|
...(lastMessage === undefined ? {} : { lastMessage }),
|
|
463
|
+
...(working ? { working: true } : {}),
|
|
452
464
|
};
|
|
453
465
|
}
|
|
454
466
|
|
|
@@ -528,7 +540,13 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
|
|
|
528
540
|
exactKeys(
|
|
529
541
|
value,
|
|
530
542
|
["schemaVersion", "botId", "count", "capped", "unread", "manuallyUnread"],
|
|
531
|
-
[
|
|
543
|
+
[
|
|
544
|
+
"lastActivityCursor",
|
|
545
|
+
"lastActivityAt",
|
|
546
|
+
"lastViewedAt",
|
|
547
|
+
"lastMessage",
|
|
548
|
+
"working",
|
|
549
|
+
],
|
|
532
550
|
"unread view",
|
|
533
551
|
);
|
|
534
552
|
if (value.schemaVersion !== 1) {
|
|
@@ -563,6 +581,9 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
|
|
|
563
581
|
);
|
|
564
582
|
const lastViewedAt = optionalTimestamp(value, "lastViewedAt", "unread view");
|
|
565
583
|
const lastMessage = optionalSidebarMessagePreviewV1(value.lastMessage);
|
|
584
|
+
if (value.working !== undefined && typeof value.working !== "boolean") {
|
|
585
|
+
throw new UnreadDecodeError("unread view working is invalid");
|
|
586
|
+
}
|
|
566
587
|
return {
|
|
567
588
|
schemaVersion: 1,
|
|
568
589
|
botId: value.botId,
|
|
@@ -574,6 +595,7 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
|
|
|
574
595
|
...(lastActivityAt === undefined ? {} : { lastActivityAt }),
|
|
575
596
|
...(lastViewedAt === undefined ? {} : { lastViewedAt }),
|
|
576
597
|
...(lastMessage === undefined ? {} : { lastMessage }),
|
|
598
|
+
...(value.working === true ? { working: true } : {}),
|
|
577
599
|
};
|
|
578
600
|
}
|
|
579
601
|
|