@frockbot/plugin-shell 0.3.9 → 0.3.10
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 -31
- package/src/backend-configuration.test.ts +3 -1
- package/src/backend-package-catalog.ts +59 -31
- package/src/backend.ts +75 -13
- 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/composition-manifest.test.ts +126 -0
- package/src/composition-manifest.ts +55 -0
- package/src/notification-id.test.ts +67 -0
- package/src/notification-id.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"./frockbot.json": "./frockbot.json",
|
|
31
31
|
"./host": "./src/host.ts",
|
|
32
32
|
"./manifest": "./src/manifest.ts",
|
|
33
|
+
"./notification-id": "./src/notification-id.ts",
|
|
33
34
|
"./package.json": "./package.json",
|
|
34
35
|
"./run-cursor": "./src/run-cursor.ts",
|
|
35
36
|
"./run-protocol": "./src/run-protocol.ts",
|
|
@@ -47,41 +48,41 @@
|
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
50
|
-
"@frockbot/agent-runtime": "0.3.
|
|
51
|
-
"@frockbot/application-foundation": "0.3.
|
|
52
|
-
"@frockbot/catalog-core": "0.3.
|
|
53
|
-
"@frockbot/client-core": "0.3.
|
|
54
|
-
"@frockbot/client-ui": "0.3.
|
|
55
|
-
"@frockbot/computer-core": "0.3.
|
|
56
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
57
|
-
"@frockbot/configuration-core": "0.3.
|
|
58
|
-
"@frockbot/connection-core": "0.3.
|
|
59
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
60
|
-
"@frockbot/kernel-composition": "0.3.
|
|
61
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
62
|
-
"@frockbot/kernel-do": "0.3.
|
|
63
|
-
"@frockbot/machine-protocol": "0.3.
|
|
64
|
-
"@frockbot/plugin-applets": "0.3.
|
|
65
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
66
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
67
|
-
"@frockbot/plugin-computer": "0.3.
|
|
68
|
-
"@frockbot/plugin-flock": "0.3.
|
|
69
|
-
"@frockbot/plugin-image": "0.3.
|
|
70
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
71
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
72
|
-
"@frockbot/plugin-memory": "0.3.
|
|
73
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
74
|
-
"@frockbot/plugin-routines": "0.3.
|
|
75
|
-
"@frockbot/plugin-skills": "0.3.
|
|
76
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
77
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
78
|
-
"@frockbot/protocol": "0.3.
|
|
51
|
+
"@frockbot/agent-runtime": "0.3.10",
|
|
52
|
+
"@frockbot/application-foundation": "0.3.10",
|
|
53
|
+
"@frockbot/catalog-core": "0.3.10",
|
|
54
|
+
"@frockbot/client-core": "0.3.10",
|
|
55
|
+
"@frockbot/client-ui": "0.3.10",
|
|
56
|
+
"@frockbot/computer-core": "0.3.10",
|
|
57
|
+
"@frockbot/computer-host-protocol": "0.3.10",
|
|
58
|
+
"@frockbot/configuration-core": "0.3.10",
|
|
59
|
+
"@frockbot/connection-core": "0.3.10",
|
|
60
|
+
"@frockbot/kernel-agent-loop": "0.3.10",
|
|
61
|
+
"@frockbot/kernel-composition": "0.3.10",
|
|
62
|
+
"@frockbot/kernel-contracts": "0.3.10",
|
|
63
|
+
"@frockbot/kernel-do": "0.3.10",
|
|
64
|
+
"@frockbot/machine-protocol": "0.3.10",
|
|
65
|
+
"@frockbot/plugin-applets": "0.3.10",
|
|
66
|
+
"@frockbot/plugin-authoring": "0.3.10",
|
|
67
|
+
"@frockbot/plugin-bot-template": "0.3.10",
|
|
68
|
+
"@frockbot/plugin-computer": "0.3.10",
|
|
69
|
+
"@frockbot/plugin-flock": "0.3.10",
|
|
70
|
+
"@frockbot/plugin-image": "0.3.10",
|
|
71
|
+
"@frockbot/plugin-machine-messages": "0.3.10",
|
|
72
|
+
"@frockbot/plugin-mcp": "0.3.10",
|
|
73
|
+
"@frockbot/plugin-memory": "0.3.10",
|
|
74
|
+
"@frockbot/plugin-package-catalog": "0.3.10",
|
|
75
|
+
"@frockbot/plugin-routines": "0.3.10",
|
|
76
|
+
"@frockbot/plugin-skills": "0.3.10",
|
|
77
|
+
"@frockbot/plugin-subagents": "0.3.10",
|
|
78
|
+
"@frockbot/plugin-user-machine": "0.3.10",
|
|
79
|
+
"@frockbot/protocol": "0.3.10",
|
|
79
80
|
"cordis": "4.0.0-rc.8",
|
|
80
81
|
"vue": "3.5.41"
|
|
81
82
|
},
|
|
82
83
|
"devDependencies": {
|
|
83
84
|
"@cloudflare/workers-types": "latest",
|
|
84
|
-
"@frockbot/plugin-tools": "0.3.
|
|
85
|
+
"@frockbot/plugin-tools": "0.3.10",
|
|
85
86
|
"@types/bun": "1.3.6",
|
|
86
87
|
"@types/node": "26.2.0",
|
|
87
88
|
"@vitejs/plugin-vue": "6.0.8",
|
|
@@ -600,8 +600,10 @@ describe("generic per-Turn model resolution", () => {
|
|
|
600
600
|
});
|
|
601
601
|
await expect(contribution.listNotifications()).resolves.toEqual([
|
|
602
602
|
expect.objectContaining({
|
|
603
|
+
// Colons would fail the acknowledge decoder, so the mint replaces
|
|
604
|
+
// them; a notification nobody can acknowledge 400s forever.
|
|
603
605
|
notificationId:
|
|
604
|
-
"package-connection-unavailable
|
|
606
|
+
"package-connection-unavailable-run-1-bot-authored-flock-ai-ambient",
|
|
605
607
|
title: "Connection unavailable",
|
|
606
608
|
}),
|
|
607
609
|
]);
|
|
@@ -320,7 +320,7 @@ export function createPackageCatalogHost(
|
|
|
320
320
|
change.action === "remove"
|
|
321
321
|
? change.input.packageId
|
|
322
322
|
: change.input.catalogId,
|
|
323
|
-
...(change.action === "remove"
|
|
323
|
+
...(change.action === "remove" || change.input.contentHash === undefined
|
|
324
324
|
? {}
|
|
325
325
|
: { contentHash: change.input.contentHash }),
|
|
326
326
|
}),
|
|
@@ -520,9 +520,12 @@ export function createPackageCatalogHost(
|
|
|
520
520
|
index,
|
|
521
521
|
catalogId: provenance.catalogId,
|
|
522
522
|
});
|
|
523
|
+
// A first-party member pins no bundle, so "the entry still offers
|
|
524
|
+
// what the target generation recorded" is the two hashes agreeing,
|
|
525
|
+
// both present or both absent.
|
|
523
526
|
if (
|
|
524
|
-
!targetEntry
|
|
525
|
-
targetEntry.bundle
|
|
527
|
+
!targetEntry ||
|
|
528
|
+
targetEntry.bundle?.contentHash !== provenance.contentHash
|
|
526
529
|
) {
|
|
527
530
|
return undoRefused(
|
|
528
531
|
request,
|
|
@@ -538,7 +541,9 @@ export function createPackageCatalogHost(
|
|
|
538
541
|
version: targetCatalogMember.version,
|
|
539
542
|
catalogId: provenance.catalogId,
|
|
540
543
|
catalogGeneration: provenance.catalogGeneration,
|
|
541
|
-
|
|
544
|
+
...(provenance.contentHash === undefined
|
|
545
|
+
? {}
|
|
546
|
+
: { contentHash: provenance.contentHash }),
|
|
542
547
|
};
|
|
543
548
|
}
|
|
544
549
|
const receipt = await options.user.execute(command);
|
|
@@ -673,17 +678,22 @@ export function createPackageCatalogHost(
|
|
|
673
678
|
const resolved = await pinnedEntry(request.change.input.catalogId);
|
|
674
679
|
user = resolved.user;
|
|
675
680
|
entry = resolved.entry;
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
681
|
+
// A first-party entry names reviewed compiled-in code and publishes
|
|
682
|
+
// no bundle: there is no artifact to verify and no hash to pin. The
|
|
683
|
+
// Plugins page has always installed such an entry; refusing it here
|
|
684
|
+
// is what made `package_install` by chat disagree with the UI (F4).
|
|
685
|
+
if (entry.bundle) {
|
|
686
|
+
if (entry.bundle.contentHash !== request.change.input.contentHash) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
`Catalog entry "${entry.catalogId}" has content hash "${entry.bundle.contentHash}", not "${request.change.input.contentHash}"`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
await verifyArtifact(entry);
|
|
692
|
+
} else if (request.change.input.contentHash !== undefined) {
|
|
682
693
|
throw new Error(
|
|
683
|
-
`Catalog entry "${entry.catalogId}"
|
|
694
|
+
`Catalog entry "${entry.catalogId}" does not carry a Package bundle`,
|
|
684
695
|
);
|
|
685
696
|
}
|
|
686
|
-
await verifyArtifact(entry);
|
|
687
697
|
const member = base.members.find(
|
|
688
698
|
(candidate) => candidate.packageId === entry.packageId,
|
|
689
699
|
);
|
|
@@ -716,7 +726,7 @@ export function createPackageCatalogHost(
|
|
|
716
726
|
version: entry.version,
|
|
717
727
|
catalogId: entry.catalogId,
|
|
718
728
|
catalogGeneration: resolved.generation,
|
|
719
|
-
contentHash: entry.bundle.contentHash,
|
|
729
|
+
...(entry.bundle ? { contentHash: entry.bundle.contentHash } : {}),
|
|
720
730
|
manifestHash: entry.manifestHash,
|
|
721
731
|
summary:
|
|
722
732
|
request.change.input.summary ??
|
|
@@ -744,16 +754,24 @@ export function createPackageCatalogHost(
|
|
|
744
754
|
// Intent and manifest before the User settings effect. The manifest key
|
|
745
755
|
// is the existing isolate loader seam used by authored Packages.
|
|
746
756
|
if (!existingIntent) {
|
|
757
|
+
const bundle = entry.bundle;
|
|
747
758
|
await options.storage.put({
|
|
748
759
|
[packageCatalogChangeIntentKey(request.effectId)]: intent,
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
760
|
+
// A bundle-less entry has no manifest document to store; the mount
|
|
761
|
+
// resolves it from the compiled-in application instead, still
|
|
762
|
+
// against the `manifestHash` the generation records.
|
|
763
|
+
...(bundle
|
|
764
|
+
? {
|
|
765
|
+
[authorshipManifestKey(intent.manifestHash)]: {
|
|
766
|
+
schemaVersion: 1,
|
|
767
|
+
manifestHash: intent.manifestHash,
|
|
768
|
+
packageId: intent.packageId,
|
|
769
|
+
version: intent.version,
|
|
770
|
+
manifest: bundle.manifest,
|
|
771
|
+
createdAt: intent.recordedAt,
|
|
772
|
+
} satisfies AuthoredManifestRecordV1,
|
|
773
|
+
}
|
|
774
|
+
: {}),
|
|
757
775
|
});
|
|
758
776
|
existingIntent = intent;
|
|
759
777
|
}
|
|
@@ -776,7 +794,9 @@ export function createPackageCatalogHost(
|
|
|
776
794
|
version: intent.version,
|
|
777
795
|
catalogId: intent.catalogId,
|
|
778
796
|
catalogGeneration: intent.catalogGeneration,
|
|
779
|
-
|
|
797
|
+
...(intent.contentHash === undefined
|
|
798
|
+
? {}
|
|
799
|
+
: { contentHash: intent.contentHash }),
|
|
780
800
|
};
|
|
781
801
|
const receipt = await options.user.execute(command);
|
|
782
802
|
if (receipt.status === "rejected") {
|
|
@@ -789,7 +809,7 @@ export function createPackageCatalogHost(
|
|
|
789
809
|
(member) => member.packageId !== intent.packageId,
|
|
790
810
|
);
|
|
791
811
|
if (action !== "remove") {
|
|
792
|
-
const bundle = entry.bundle
|
|
812
|
+
const bundle = entry.bundle;
|
|
793
813
|
const member: CompositionMemberV1 = {
|
|
794
814
|
packageId: entry.packageId,
|
|
795
815
|
specifier: `catalog:${entry.catalogId}`,
|
|
@@ -801,14 +821,20 @@ export function createPackageCatalogHost(
|
|
|
801
821
|
version: entry.version,
|
|
802
822
|
catalogId: entry.catalogId,
|
|
803
823
|
catalogGeneration: intent.catalogGeneration,
|
|
804
|
-
contentHash: bundle.contentHash,
|
|
805
|
-
},
|
|
806
|
-
artifact: {
|
|
807
|
-
contentHash: bundle.contentHash,
|
|
808
|
-
size: bundle.size,
|
|
809
|
-
mediaType: bundle.mediaType,
|
|
810
|
-
bundlerVersion: bundle.bundlerVersion,
|
|
824
|
+
...(bundle ? { contentHash: bundle.contentHash } : {}),
|
|
811
825
|
},
|
|
826
|
+
// No artifact ⇒ the member mounts in the kernel isolate from the
|
|
827
|
+
// compiled-in Package, exactly as the UI install records it.
|
|
828
|
+
...(bundle
|
|
829
|
+
? {
|
|
830
|
+
artifact: {
|
|
831
|
+
contentHash: bundle.contentHash,
|
|
832
|
+
size: bundle.size,
|
|
833
|
+
mediaType: bundle.mediaType,
|
|
834
|
+
bundlerVersion: bundle.bundlerVersion,
|
|
835
|
+
},
|
|
836
|
+
}
|
|
837
|
+
: {}),
|
|
812
838
|
};
|
|
813
839
|
members.push(member);
|
|
814
840
|
}
|
|
@@ -850,7 +876,9 @@ export function createPackageCatalogHost(
|
|
|
850
876
|
packageId: intent.packageId,
|
|
851
877
|
displayName: intent.displayName,
|
|
852
878
|
...(action === "remove" ? {} : { version: intent.version }),
|
|
853
|
-
...(action === "remove"
|
|
879
|
+
...(action === "remove" || intent.contentHash === undefined
|
|
880
|
+
? {}
|
|
881
|
+
: { contentHash: intent.contentHash }),
|
|
854
882
|
generationId: generation.generationId,
|
|
855
883
|
missingConnectionTypes: intent.missingConnectionTypes,
|
|
856
884
|
recordedAt: intent.recordedAt,
|
package/src/backend.ts
CHANGED
|
@@ -415,6 +415,12 @@ import {
|
|
|
415
415
|
type ClientRunV1,
|
|
416
416
|
type ClientTurnV1,
|
|
417
417
|
} from "./run-protocol.js";
|
|
418
|
+
import { notificationIdV1 } from "./notification-id.js";
|
|
419
|
+
import {
|
|
420
|
+
type CompositionManifestSourcesV1,
|
|
421
|
+
compositionMemberManifestDocumentV1,
|
|
422
|
+
compositionMemberManifestV1,
|
|
423
|
+
} from "./composition-manifest.js";
|
|
418
424
|
import {
|
|
419
425
|
BOT_DEBUG_DEFAULT_RUN_LIMIT_V1,
|
|
420
426
|
BOT_DEBUG_EVENT_BYTES_V1,
|
|
@@ -1233,15 +1239,44 @@ export class ShellBotBackendContribution {
|
|
|
1233
1239
|
return { schemaVersion: 1, skills: entries };
|
|
1234
1240
|
}
|
|
1235
1241
|
|
|
1236
|
-
/**
|
|
1237
|
-
|
|
1242
|
+
/**
|
|
1243
|
+
* The one durable manifest lookup used by mounts, commands, and UI views —
|
|
1244
|
+
* as the **stored document**, byte-for-byte what `manifestHash` was taken
|
|
1245
|
+
* over at authoring time.
|
|
1246
|
+
*
|
|
1247
|
+
* Decoding rebuilds the object (`decodeV5` always writes a `configuration`
|
|
1248
|
+
* key, for one), so a decoded manifest does not canonicalize back to the
|
|
1249
|
+
* recorded hash. Every mount re-verifies that hash
|
|
1250
|
+
* (`botIsolatePackageDescriptorV1`), so the raw document is the only thing
|
|
1251
|
+
* that can be handed to it; callers that want the typed shape decode it
|
|
1252
|
+
* themselves through `readCompositionMemberManifest`.
|
|
1253
|
+
*/
|
|
1254
|
+
private compositionManifestSources(): CompositionManifestSourcesV1 {
|
|
1255
|
+
return {
|
|
1256
|
+
stored: (manifestHash) =>
|
|
1257
|
+
this.ctx.storage.get<AuthoredManifestRecordV1>(
|
|
1258
|
+
authorshipManifestKey(manifestHash),
|
|
1259
|
+
),
|
|
1260
|
+
application: (member) => this.readApplicationMemberManifest(member),
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
private readCompositionMemberManifestDocument(
|
|
1265
|
+
member: CompositionMemberV1,
|
|
1266
|
+
): Promise<unknown | undefined> {
|
|
1267
|
+
return compositionMemberManifestDocumentV1(
|
|
1268
|
+
member,
|
|
1269
|
+
this.compositionManifestSources(),
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
private readCompositionMemberManifest(
|
|
1238
1274
|
member: CompositionMemberV1,
|
|
1239
1275
|
): Promise<FrockBotManifest | undefined> {
|
|
1240
|
-
|
|
1241
|
-
|
|
1276
|
+
return compositionMemberManifestV1(
|
|
1277
|
+
member,
|
|
1278
|
+
this.compositionManifestSources(),
|
|
1242
1279
|
);
|
|
1243
|
-
if (stored) return decodeFrockBotManifest(stored.manifest);
|
|
1244
|
-
return await this.readApplicationMemberManifest(member);
|
|
1245
1280
|
}
|
|
1246
1281
|
|
|
1247
1282
|
/**
|
|
@@ -1270,6 +1305,19 @@ export class ShellBotBackendContribution {
|
|
|
1270
1305
|
return declared.manifest;
|
|
1271
1306
|
}
|
|
1272
1307
|
|
|
1308
|
+
/** The stored manifest document a mount hashes, or a modelled failure. */
|
|
1309
|
+
private async requireCompositionMemberManifestDocument(
|
|
1310
|
+
member: CompositionMemberV1,
|
|
1311
|
+
): Promise<unknown> {
|
|
1312
|
+
const document = await this.readCompositionMemberManifestDocument(member);
|
|
1313
|
+
if (document === undefined) {
|
|
1314
|
+
throw new Error(
|
|
1315
|
+
`package "${member.packageId}" manifest "${member.manifestHash}" is unavailable`,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
return document;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1273
1321
|
private async requireCompositionMemberManifest(
|
|
1274
1322
|
member: CompositionMemberV1,
|
|
1275
1323
|
): Promise<FrockBotManifest> {
|
|
@@ -1832,7 +1880,11 @@ export class ShellBotBackendContribution {
|
|
|
1832
1880
|
fallback: CompositionGenerationV1,
|
|
1833
1881
|
): Promise<void> {
|
|
1834
1882
|
await this.authority.recordNotification({
|
|
1835
|
-
notificationId:
|
|
1883
|
+
notificationId: notificationIdV1(
|
|
1884
|
+
"composition-failure",
|
|
1885
|
+
failure.generationId,
|
|
1886
|
+
failure.attempt,
|
|
1887
|
+
),
|
|
1836
1888
|
runId,
|
|
1837
1889
|
createdAt: failure.at,
|
|
1838
1890
|
title: `${settings.profile.name} kept its last working Packages`,
|
|
@@ -1883,7 +1935,8 @@ export class ShellBotBackendContribution {
|
|
|
1883
1935
|
artifacts,
|
|
1884
1936
|
this.bundledPackageArtifacts,
|
|
1885
1937
|
),
|
|
1886
|
-
manifestFor: (member) =>
|
|
1938
|
+
manifestFor: (member) =>
|
|
1939
|
+
this.requireCompositionMemberManifestDocument(member),
|
|
1887
1940
|
capabilitiesFor: (member) =>
|
|
1888
1941
|
mintCapabilities({
|
|
1889
1942
|
props: {
|
|
@@ -2692,7 +2745,12 @@ export class ShellBotBackendContribution {
|
|
|
2692
2745
|
);
|
|
2693
2746
|
if (!connection?.generation) {
|
|
2694
2747
|
await this.authority.recordNotification({
|
|
2695
|
-
notificationId:
|
|
2748
|
+
notificationId: notificationIdV1(
|
|
2749
|
+
"package-connection-unavailable",
|
|
2750
|
+
input.runId,
|
|
2751
|
+
input.packageId,
|
|
2752
|
+
input.request,
|
|
2753
|
+
),
|
|
2696
2754
|
runId: input.runId,
|
|
2697
2755
|
createdAt: new Date().toISOString(),
|
|
2698
2756
|
title: "Connection unavailable",
|
|
@@ -2720,7 +2778,11 @@ export class ShellBotBackendContribution {
|
|
|
2720
2778
|
}
|
|
2721
2779
|
const request = decodeIsolateNotificationRequestV1(input.request);
|
|
2722
2780
|
await this.authority.recordNotification({
|
|
2723
|
-
notificationId:
|
|
2781
|
+
notificationId: notificationIdV1(
|
|
2782
|
+
"package",
|
|
2783
|
+
input.packageId,
|
|
2784
|
+
request.notificationId,
|
|
2785
|
+
),
|
|
2724
2786
|
runId: input.runId,
|
|
2725
2787
|
createdAt: new Date().toISOString(),
|
|
2726
2788
|
title: request.title,
|
|
@@ -3107,7 +3169,7 @@ export class ShellBotBackendContribution {
|
|
|
3107
3169
|
await this.authority.recordNotification({
|
|
3108
3170
|
// The same id shape the completion path uses, so one firing is one
|
|
3109
3171
|
// intent however many times the alarm retries it.
|
|
3110
|
-
notificationId:
|
|
3172
|
+
notificationId: notificationIdV1("routine-failed", fire.fireId),
|
|
3111
3173
|
runId: fire.fireId,
|
|
3112
3174
|
createdAt: new Date().toISOString(),
|
|
3113
3175
|
title: `${settings.profile.name} could not run a Routine`,
|
|
@@ -3660,7 +3722,7 @@ export class ShellBotBackendContribution {
|
|
|
3660
3722
|
}
|
|
3661
3723
|
if (!settings.notifications.enabled) return;
|
|
3662
3724
|
await this.authority.recordNotification({
|
|
3663
|
-
notificationId:
|
|
3725
|
+
notificationId: notificationIdV1("task-settled", task.taskId),
|
|
3664
3726
|
runId: task.taskId,
|
|
3665
3727
|
createdAt: at,
|
|
3666
3728
|
title: `${settings.profile.name} finished a subagent task`,
|
|
@@ -5464,7 +5526,7 @@ export class ShellBotBackendContribution {
|
|
|
5464
5526
|
// `automation_completion_inbox` row".
|
|
5465
5527
|
if (handoff === undefined) return undefined;
|
|
5466
5528
|
return {
|
|
5467
|
-
notificationId:
|
|
5529
|
+
notificationId: notificationIdV1("routine-wake", result.runId),
|
|
5468
5530
|
runId: result.runId,
|
|
5469
5531
|
createdAt: new Date().toISOString(),
|
|
5470
5532
|
title: `${settings.profile.name} finished a Routine`,
|
package/src/client/index.ts
CHANGED
|
@@ -1228,6 +1228,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1228
1228
|
packageDisplayName: catalogPackage?.displayName,
|
|
1229
1229
|
connectionDisplayName: connection?.displayName,
|
|
1230
1230
|
failure: effective.binding?.failure,
|
|
1231
|
+
fallback: Boolean(effective.fallback),
|
|
1231
1232
|
});
|
|
1232
1233
|
}
|
|
1233
1234
|
|
|
@@ -35,6 +35,20 @@ describe("model runtime presentation", () => {
|
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test("names the stand-in model when the chosen one is unavailable", () => {
|
|
39
|
+
// The Bot is answering, so the line is not a failure — it names the model
|
|
40
|
+
// actually in use and says the User's own choice is not it.
|
|
41
|
+
expect(
|
|
42
|
+
modelRuntimeLabel({
|
|
43
|
+
source: "platform",
|
|
44
|
+
modelDisplayName: "Auto",
|
|
45
|
+
providerModelId: "@flock/auto",
|
|
46
|
+
packageDisplayName: "Flock AI",
|
|
47
|
+
fallback: true,
|
|
48
|
+
}),
|
|
49
|
+
).toBe("Auto · Flock AI · your chosen model is unavailable");
|
|
50
|
+
});
|
|
51
|
+
|
|
38
52
|
test("shows unavailable and backend failure states", () => {
|
|
39
53
|
expect(modelRuntimeLabel({ source: "none" })).toBe(
|
|
40
54
|
"No model available — set one up in Models",
|
|
@@ -11,6 +11,11 @@ export function modelRuntimeLabel(input: {
|
|
|
11
11
|
packageDisplayName?: string;
|
|
12
12
|
connectionDisplayName?: string;
|
|
13
13
|
failure?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The chosen model could not bind — its provider Package is off, or its
|
|
16
|
+
* Connection is gone — and the platform default is answering in its place.
|
|
17
|
+
*/
|
|
18
|
+
fallback?: boolean;
|
|
14
19
|
}): string {
|
|
15
20
|
if (input.failure) return input.failure;
|
|
16
21
|
if (input.source === "none" || !input.providerModelId) {
|
|
@@ -22,5 +27,6 @@ export function modelRuntimeLabel(input: {
|
|
|
22
27
|
const runtime = provider ? `${model} · ${provider}` : model;
|
|
23
28
|
if (input.source === "bot") return `${runtime} · this Bot only`;
|
|
24
29
|
if (input.source === "account") return `${runtime} · Account model`;
|
|
30
|
+
if (input.fallback) return `${runtime} · your chosen model is unavailable`;
|
|
25
31
|
return runtime;
|
|
26
32
|
}
|
|
@@ -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,67 @@
|
|
|
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("distinct parts stay distinct, even past the length ceiling", () => {
|
|
54
|
+
const long = "x".repeat(400);
|
|
55
|
+
const a = notificationIdV1("package", long, "one");
|
|
56
|
+
const b = notificationIdV1("package", long, "two");
|
|
57
|
+
expect(a).not.toBe(b);
|
|
58
|
+
expect(a.length).toBeLessThanOrEqual(128);
|
|
59
|
+
expect(acknowledge(a)).toBe(a);
|
|
60
|
+
expect(acknowledge(b)).toBe(b);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parts that sanitize to nothing still mint an acknowledgeable id", () => {
|
|
64
|
+
const id = notificationIdV1(":::", "::");
|
|
65
|
+
expect(acknowledge(id)).toBe(id);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isPublicIdentifier } from "@frockbot/configuration-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The acknowledge decoder's ceiling: `isPublicIdentifier` admits a leading
|
|
5
|
+
* alphanumeric plus at most 127 more characters.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_NOTIFICATION_ID_LENGTH = 128;
|
|
8
|
+
|
|
9
|
+
/** How much of a folded id the digest suffix costs: `-` plus eight hex. */
|
|
10
|
+
const DIGEST_SUFFIX_LENGTH = 9;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A notification is only useful if it can be acknowledged, and acknowledgement
|
|
14
|
+
* decodes the id through `isPublicIdentifier`
|
|
15
|
+
* (`/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/`) — which admits no colons. Delivery
|
|
16
|
+
* only bounds the length, so an id minted by hand out of a generation id
|
|
17
|
+
* (`2026-09-03T23:49:00.416Z:dc03a32d9b717619`) or a package request went out,
|
|
18
|
+
* came back, and was refused with `400` on every poll for the life of the Bot.
|
|
19
|
+
*
|
|
20
|
+
* Every notification id is minted here instead, so the two sides cannot drift:
|
|
21
|
+
* the parts are joined with `-`, anything outside the admitted alphabet
|
|
22
|
+
* becomes `-`, and an over-long id keeps its readable head and folds the rest
|
|
23
|
+
* into a digest so distinct inputs stay distinct ids. Ids are stable for the
|
|
24
|
+
* same parts, which is what makes one firing one intent however many times a
|
|
25
|
+
* retry re-mints it.
|
|
26
|
+
*/
|
|
27
|
+
export function notificationIdV1(
|
|
28
|
+
...parts: readonly (string | number)[]
|
|
29
|
+
): string {
|
|
30
|
+
const raw = parts.map((part) => String(part)).join("-");
|
|
31
|
+
const sanitized = raw
|
|
32
|
+
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
33
|
+
.replace(/^[^a-zA-Z0-9]+/, "");
|
|
34
|
+
const seeded =
|
|
35
|
+
sanitized.length === 0 ? `notification-${digest(raw)}` : sanitized;
|
|
36
|
+
const bounded =
|
|
37
|
+
seeded.length <= MAX_NOTIFICATION_ID_LENGTH
|
|
38
|
+
? seeded
|
|
39
|
+
: `${seeded.slice(0, MAX_NOTIFICATION_ID_LENGTH - DIGEST_SUFFIX_LENGTH)}-${digest(raw)}`;
|
|
40
|
+
if (!isPublicIdentifier(bounded)) {
|
|
41
|
+
// Unreachable by construction; a mint that cannot be acknowledged is a
|
|
42
|
+
// bug, never something to ship to a client that will 400 on it forever.
|
|
43
|
+
throw new Error(
|
|
44
|
+
`minted notification id "${bounded}" is not acknowledgeable`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return bounded;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** FNV-1a, eight hex digits — an id disambiguator, not a security claim. */
|
|
51
|
+
function digest(value: string): string {
|
|
52
|
+
let hash = 0x811c9dc5;
|
|
53
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
54
|
+
hash ^= value.charCodeAt(index);
|
|
55
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
56
|
+
}
|
|
57
|
+
return hash.toString(16).padStart(8, "0");
|
|
58
|
+
}
|