@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.9",
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.9",
51
- "@frockbot/application-foundation": "0.3.9",
52
- "@frockbot/catalog-core": "0.3.9",
53
- "@frockbot/client-core": "0.3.9",
54
- "@frockbot/client-ui": "0.3.9",
55
- "@frockbot/computer-core": "0.3.9",
56
- "@frockbot/computer-host-protocol": "0.3.9",
57
- "@frockbot/configuration-core": "0.3.9",
58
- "@frockbot/connection-core": "0.3.9",
59
- "@frockbot/kernel-agent-loop": "0.3.9",
60
- "@frockbot/kernel-composition": "0.3.9",
61
- "@frockbot/kernel-contracts": "0.3.9",
62
- "@frockbot/kernel-do": "0.3.9",
63
- "@frockbot/machine-protocol": "0.3.9",
64
- "@frockbot/plugin-applets": "0.3.9",
65
- "@frockbot/plugin-authoring": "0.3.9",
66
- "@frockbot/plugin-bot-template": "0.3.9",
67
- "@frockbot/plugin-computer": "0.3.9",
68
- "@frockbot/plugin-flock": "0.3.9",
69
- "@frockbot/plugin-image": "0.3.9",
70
- "@frockbot/plugin-machine-messages": "0.3.9",
71
- "@frockbot/plugin-mcp": "0.3.9",
72
- "@frockbot/plugin-memory": "0.3.9",
73
- "@frockbot/plugin-package-catalog": "0.3.9",
74
- "@frockbot/plugin-routines": "0.3.9",
75
- "@frockbot/plugin-skills": "0.3.9",
76
- "@frockbot/plugin-subagents": "0.3.9",
77
- "@frockbot/plugin-user-machine": "0.3.9",
78
- "@frockbot/protocol": "0.3.9",
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.9",
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:run-1:bot-authored:flock-ai-ambient",
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?.bundle ||
525
- targetEntry.bundle.contentHash !== provenance.contentHash
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
- contentHash: provenance.contentHash,
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
- if (!entry.bundle) {
677
- throw new Error(
678
- `Catalog entry "${entry.catalogId}" does not carry installable code`,
679
- );
680
- }
681
- if (entry.bundle.contentHash !== request.change.input.contentHash) {
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}" has content hash "${entry.bundle.contentHash}", not "${request.change.input.contentHash}"`,
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
- [authorshipManifestKey(intent.manifestHash)]: {
750
- schemaVersion: 1,
751
- manifestHash: intent.manifestHash,
752
- packageId: intent.packageId,
753
- version: intent.version,
754
- manifest: entry.bundle!.manifest,
755
- createdAt: intent.recordedAt,
756
- } satisfies AuthoredManifestRecordV1,
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
- contentHash: intent.contentHash,
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" ? {} : { contentHash: intent.contentHash }),
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
- /** The one durable manifest lookup used by mounts, commands, and UI views. */
1237
- private async readCompositionMemberManifest(
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
- const stored = await this.ctx.storage.get<AuthoredManifestRecordV1>(
1241
- authorshipManifestKey(member.manifestHash),
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: `composition-failure:${failure.generationId}:${failure.attempt}`,
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) => this.requireCompositionMemberManifest(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: `package-connection-unavailable:${input.runId}:${input.packageId}:${input.request}`,
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: `package:${input.packageId}:${request.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: `routine-failed:${fire.fireId}`,
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: `task-settled:${task.taskId}`,
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: `routine-wake:${result.runId}`,
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`,
@@ -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
+ }