@frockbot/plugin-shell 0.1.4 → 0.2.1

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.
@@ -1,119 +1,61 @@
1
1
  // The Bot Durable Object half of the isolate capability boundary.
2
2
  //
3
- // A loaded Bot Package sees exactly two bindings: `IDENTITY` (a plain object)
4
- // and `CAPABILITIES` (a loopback service binding). Every method behind
5
- // `CAPABILITIES` ends up here, in the authority that owns the Bot's durable
6
- // state so an authority-widening request becomes a durable pending decision
7
- // rather than a grant, and a model call records its normalized request and
8
- // acquires its credential lease through the existing provider path before a
9
- // single byte leaves the account.
3
+ // Every Package mounted for one Bot receives the same authority projection:
4
+ // the User's ready Connections, the Bot's configured model, and the shared
5
+ // tool, Memory, Workspace, and notification surfaces. Package identity remains
6
+ // in props only for attribution; it never narrows the authority list.
10
7
  import type {
11
- IsolateCapabilityFailureV1,
12
- IsolateAuthorityRequestV1,
13
- IsolateCapabilityDescriptorV1,
14
- IsolateModelOutcomeV1,
15
- IsolatePendingDecisionV1,
8
+ IsolateCapabilityListV1,
9
+ IsolateConnectionLeaseV1,
10
+ IsolateConnectionOutcomeV1,
11
+ IsolateConnectionV1,
12
+ IsolateModelBindingV1,
13
+ IsolateModelInvocationV1,
16
14
  LlmStreamEvent,
17
15
  NormalizedModelRequest,
18
16
  } from "@frockbot/kernel-contracts";
19
- import {
20
- decodeIsolateAuthorityRequestV1,
21
- encodeIsolateModelEventLineV1,
22
- } from "@frockbot/kernel-contracts";
17
+ import { encodeIsolateModelEventLineV1 } from "@frockbot/kernel-contracts";
23
18
  import type { BotIsolateArtifactStore } from "@frockbot/kernel-composition/isolate";
24
- import type { EnabledCapabilityV1 } from "@frockbot/configuration-core";
25
19
 
26
- /**
27
- * The compatibility date every Bot isolate is loaded with. Pinned beside the
28
- * gateway Worker's own so Bot-authored code cannot outrun the kernel wrapper.
29
- */
20
+ export type { IsolateModelBindingV1 } from "@frockbot/kernel-contracts";
21
+
30
22
  export const BOT_ISOLATE_COMPATIBILITY_DATE = "2026-08-27";
31
23
 
32
- /**
33
- * What travels in `ctx.exports.BotCapabilities({ props })`. Structured-clonable
34
- * by necessity: props cross into a loopback service binding.
35
- */
36
24
  export interface BotCapabilitiesPropsV1 {
37
25
  userId: string;
38
26
  botId: string;
27
+ runId: string;
28
+ sessionId: string;
29
+ turnId: string;
39
30
  generationId: string;
40
31
  packageId: string;
41
- /** The User's enabled set, already resolved by the authority. */
42
- capabilities: IsolateCapabilityV1[];
32
+ connections: IsolateConnectionV1[];
33
+ model?: IsolateModelBindingV1;
34
+ memory: boolean;
35
+ workspace: boolean;
43
36
  }
44
37
 
45
- export const ISOLATE_DECISION_PREFIX = "isolate:decision:";
46
38
  export const ISOLATE_MODEL_REQUEST_PREFIX = "isolate:model-request:";
47
39
 
48
- /** A durable record that the Bot asked for authority it does not hold. */
49
- export interface IsolatePendingAuthorityDecisionV1 {
50
- schemaVersion: 1;
51
- decisionId: string;
52
- botId: string;
53
- packageId: string;
54
- generationId: string;
55
- capabilityId: string;
56
- reason: string;
57
- requestedAt: string;
58
- status: "pending";
59
- }
60
-
61
- /** The intent recorded before a Bot-authored adapter's model call is forwarded. */
62
40
  export interface IsolateModelRequestRecordV1 {
63
41
  schemaVersion: 1;
64
- /** Minted by this Durable Object; the record is keyed by it. */
65
42
  recordId: string;
66
- /** The Bot's own correlation id. Bounded, and never a storage key. */
67
43
  requestId: string;
68
44
  botId: string;
69
45
  packageId: string;
70
46
  generationId: string;
71
- capabilityId: string;
72
47
  request: NormalizedModelRequest;
73
48
  recordedAt: string;
74
49
  }
75
50
 
76
- /** The narrow storage surface this module needs from the Durable Object. */
77
51
  export interface IsolateCapabilityStore {
78
52
  put(key: string, value: unknown): Promise<void>;
79
- get<T>(key: string): Promise<T | undefined>;
80
53
  list<T>(options: { prefix: string }): Promise<Map<string, T>>;
81
54
  }
82
55
 
83
- /** One account-wide Capability, already resolved from User enablement. */
84
- export type IsolateCapabilityV1 = EnabledCapabilityV1;
85
-
86
- /**
87
- * The Bot's effective model binding, resolved by the authority from generic
88
- * Package settings and the User's Connection — never from anything the Bot
89
- * supplied. An `invokeModel` request is authorized only when it names exactly
90
- * this provider and this model, and it is forwarded carrying exactly this
91
- * binding.
92
- */
93
- export interface IsolateModelBindingV1 {
94
- packageId: string;
95
- capabilityId: string;
96
- connectionId: string;
97
- provider: string;
98
- providerModelId: string;
99
- connectionGeneration?: string;
100
- catalogGeneration?: string;
101
- }
102
-
103
- /** A configured model binding whose Connection cannot currently be used. */
104
- export interface IsolateUnavailableModelBindingV1 {
105
- provider?: string;
106
- providerModelId: string;
107
- }
108
-
109
- export const ISOLATE_MODEL_UNAVAILABLE_MESSAGE =
110
- "the enabled model binding is unavailable";
111
-
112
- /** The bound Bot-supplied correlation id: a field, never a key, and never unbounded. */
113
56
  export const MAX_ISOLATE_REQUEST_ID = 256;
114
57
 
115
58
  export interface IsolateModelPath {
116
- /** Streams through the mounted provider Plugin; the lease is taken inside it. */
117
59
  stream(
118
60
  request: NormalizedModelRequest,
119
61
  signal: AbortSignal,
@@ -125,56 +67,56 @@ export interface IsolateCapabilityHostOptions {
125
67
  botId: string;
126
68
  packageId: string;
127
69
  generationId: string;
128
- /** User-enabled and nothing else. */
129
- capabilities: readonly IsolateCapabilityV1[];
130
- /**
131
- * The one ready model binding resolved for this Bot. Absent without an
132
- * unavailable binding means every model request is an authority-widening
133
- * pending decision.
134
- */
70
+ connections: readonly IsolateConnectionV1[];
135
71
  modelBinding?: IsolateModelBindingV1;
136
- /** Present when the configured binding is held but its Connection is unavailable. */
137
- unavailableModelBinding?: IsolateUnavailableModelBindingV1;
138
- /** Present only while the ready binding can reach its provider path. */
139
72
  modelPath?: IsolateModelPath;
73
+ memory: boolean;
74
+ workspace: boolean;
140
75
  now?(): Date;
141
76
  newId?(): string;
142
77
  }
143
78
 
144
79
  export interface IsolateCapabilityHost {
145
- list(): Promise<IsolateCapabilityDescriptorV1[]>;
146
- requestAuthority(request: unknown): Promise<IsolatePendingDecisionV1>;
147
- invokeModel(request: NormalizedModelRequest): Promise<IsolateModelOutcomeV1>;
148
- pendingDecisions(): Promise<IsolatePendingAuthorityDecisionV1[]>;
80
+ list(): Promise<IsolateCapabilityListV1>;
81
+ invokeModel(
82
+ request: NormalizedModelRequest,
83
+ ): Promise<IsolateModelInvocationV1>;
149
84
  recordedModelRequests(): Promise<IsolateModelRequestRecordV1[]>;
150
85
  }
151
86
 
152
- /**
153
- * The enabled model Capability that can serve this request, if any.
154
- *
155
- * The authority resolves the effective model against one User-enabled
156
- * Package, Connection, and Capability. A request naming any other provider or
157
- * model resolves to nothing, whatever the Bot claims about it — the
158
- * Bot-supplied `modelBinding` is never read here or anywhere downstream.
159
- */
160
- export function matchingModelCapabilityV1(
161
- capabilities: readonly IsolateCapabilityV1[],
87
+ export function matchingModelBindingV1(
162
88
  binding: IsolateModelBindingV1 | undefined,
163
89
  request: NormalizedModelRequest,
164
- ): IsolateCapabilityV1 | undefined {
165
- if (!binding) return undefined;
90
+ ): IsolateModelBindingV1 | undefined {
91
+ const admitted = request.modelBinding;
166
92
  if (
93
+ !binding ||
94
+ !admitted ||
167
95
  request.provider !== binding.provider ||
168
- request.model !== binding.providerModelId
96
+ request.model !== binding.providerModelId ||
97
+ admitted.connectionId !== binding.connectionId ||
98
+ admitted.connectionGeneration !== binding.connectionGeneration ||
99
+ admitted.catalogGeneration !== binding.catalogGeneration
169
100
  ) {
170
101
  return undefined;
171
102
  }
172
- return capabilities.find(
173
- (capability) =>
174
- capability.kind === "model" &&
175
- capability.packageId === binding.packageId &&
176
- capability.capabilityId === binding.capabilityId &&
177
- capability.connectionId === binding.connectionId,
103
+ return binding;
104
+ }
105
+
106
+ /**
107
+ * A loaded isolate may receive only the Connection generation baked into its
108
+ * admitted authority snapshot. A later User Connection change gets a new
109
+ * binding digest and isolate identity; it must not leak through this old stub.
110
+ */
111
+ export function matchesAdmittedConnectionV1(
112
+ admitted: IsolateConnectionV1 | undefined,
113
+ outcome: IsolateConnectionOutcomeV1,
114
+ ): outcome is IsolateConnectionLeaseV1 {
115
+ return (
116
+ admitted !== undefined &&
117
+ outcome.status === "available" &&
118
+ outcome.connectionId === admitted.connectionId &&
119
+ outcome.generation === admitted.generation
178
120
  );
179
121
  }
180
122
 
@@ -184,103 +126,47 @@ export function createIsolateCapabilityHost(
184
126
  const now = options.now ?? (() => new Date());
185
127
  const newId = options.newId ?? (() => crypto.randomUUID());
186
128
 
187
- async function recordDecision(
188
- capabilityId: string,
189
- reason: string,
190
- ): Promise<IsolatePendingDecisionV1> {
191
- const decisionId = `decision-${newId()}`;
192
- const record: IsolatePendingAuthorityDecisionV1 = {
193
- schemaVersion: 1,
194
- decisionId,
195
- botId: options.botId,
196
- packageId: options.packageId,
197
- generationId: options.generationId,
198
- capabilityId,
199
- reason,
200
- requestedAt: now().toISOString(),
201
- status: "pending",
202
- };
203
- await options.storage.put(
204
- `${ISOLATE_DECISION_PREFIX}${decisionId}`,
205
- record,
206
- );
207
- return { status: "pending-user-decision", decisionId };
208
- }
209
-
210
129
  return {
211
- list(): Promise<IsolateCapabilityDescriptorV1[]> {
212
- return Promise.resolve(
213
- options.capabilities.map((capability) => ({
214
- capabilityId: capability.capabilityId,
215
- kind: capability.kind,
216
- })),
217
- );
218
- },
219
-
220
- async requestAuthority(
221
- request: unknown,
222
- ): Promise<IsolatePendingDecisionV1> {
223
- const decoded: IsolateAuthorityRequestV1 =
224
- decodeIsolateAuthorityRequestV1(request);
225
- // Self-modification never widens authority, even when the capability is
226
- // already enabled: the answer is a decision the User makes.
227
- return await recordDecision(decoded.capabilityId, decoded.reason);
130
+ list(): Promise<IsolateCapabilityListV1> {
131
+ return Promise.resolve({
132
+ status: "available",
133
+ connections: structuredClone([...options.connections]),
134
+ ...(options.modelBinding
135
+ ? { model: structuredClone(options.modelBinding) }
136
+ : {}),
137
+ tools: true,
138
+ memory: options.memory,
139
+ workspace: options.workspace,
140
+ notify: true,
141
+ schedule: true,
142
+ });
228
143
  },
229
144
 
230
145
  async invokeModel(
231
146
  request: NormalizedModelRequest,
232
- ): Promise<IsolateModelOutcomeV1> {
147
+ ): Promise<IsolateModelInvocationV1> {
233
148
  if (request.requestId.length > MAX_ISOLATE_REQUEST_ID) {
234
149
  throw new Error("isolate model request requestId is not bounded");
235
150
  }
236
- const binding = options.modelBinding;
237
- const capability = matchingModelCapabilityV1(
238
- options.capabilities,
239
- binding,
240
- request,
241
- );
242
- const unavailableBinding = options.unavailableModelBinding;
243
- if (
244
- unavailableBinding &&
245
- request.model === unavailableBinding.providerModelId &&
246
- (unavailableBinding.provider === undefined ||
247
- request.provider === unavailableBinding.provider)
248
- ) {
249
- return {
250
- status: "unavailable",
251
- reason: ISOLATE_MODEL_UNAVAILABLE_MESSAGE,
252
- } satisfies IsolateCapabilityFailureV1;
253
- }
254
- if (capability && binding && !options.modelPath) {
151
+ const binding = matchingModelBindingV1(options.modelBinding, request);
152
+ if (!binding || !options.modelPath) {
255
153
  return {
256
154
  status: "unavailable",
257
- reason: ISOLATE_MODEL_UNAVAILABLE_MESSAGE,
258
- } satisfies IsolateCapabilityFailureV1;
155
+ reason: options.modelBinding
156
+ ? "the request does not match this Bot's configured model"
157
+ : "this Bot has no configured model",
158
+ };
259
159
  }
260
- if (!capability || !binding || !options.modelPath) {
261
- return await recordDecision(
262
- `models:${request.provider}:${request.model}`,
263
- `Bot Package "${options.packageId}" asked to invoke a model with no matching enabled Capability`,
264
- );
265
- }
266
- // The binding the provider path receives is the authority's, never the
267
- // Bot's: a Bot-composed request carries no Connection authority.
268
160
  const forwarded: NormalizedModelRequest = {
269
161
  ...structuredClone(request),
270
162
  modelBinding: {
271
163
  connectionId: binding.connectionId,
272
- ...(binding.connectionGeneration
273
- ? { connectionGeneration: binding.connectionGeneration }
274
- : {}),
164
+ connectionGeneration: binding.connectionGeneration,
275
165
  ...(binding.catalogGeneration
276
166
  ? { catalogGeneration: binding.catalogGeneration }
277
167
  : {}),
278
168
  },
279
169
  };
280
- // Record the exact normalized request before forwarding; the provider
281
- // path takes the credential lease on the way through. The record is
282
- // keyed by an id this authority mints, so a Bot cannot overwrite one of
283
- // its own earlier records by reusing a `requestId`.
284
170
  const recordId = `model-request-${newId()}`;
285
171
  const record: IsolateModelRequestRecordV1 = {
286
172
  schemaVersion: 1,
@@ -289,7 +175,6 @@ export function createIsolateCapabilityHost(
289
175
  botId: options.botId,
290
176
  packageId: options.packageId,
291
177
  generationId: options.generationId,
292
- capabilityId: capability.capabilityId,
293
178
  request: forwarded,
294
179
  recordedAt: now().toISOString(),
295
180
  };
@@ -298,22 +183,16 @@ export function createIsolateCapabilityHost(
298
183
  record,
299
184
  );
300
185
  const controller = new AbortController();
301
- const events = options.modelPath.stream(forwarded, controller.signal);
302
186
  return {
303
187
  status: "streaming",
304
188
  requestId: request.requestId,
305
- events: isolateModelEventStreamV1(events, controller),
189
+ events: isolateModelEventStreamV1(
190
+ options.modelPath.stream(forwarded, controller.signal),
191
+ controller,
192
+ ),
306
193
  };
307
194
  },
308
195
 
309
- async pendingDecisions(): Promise<IsolatePendingAuthorityDecisionV1[]> {
310
- const stored =
311
- await options.storage.list<IsolatePendingAuthorityDecisionV1>({
312
- prefix: ISOLATE_DECISION_PREFIX,
313
- });
314
- return [...stored.values()];
315
- },
316
-
317
196
  async recordedModelRequests(): Promise<IsolateModelRequestRecordV1[]> {
318
197
  const stored = await options.storage.list<IsolateModelRequestRecordV1>({
319
198
  prefix: ISOLATE_MODEL_REQUEST_PREFIX,
@@ -366,51 +245,37 @@ export function isolateModelEventStreamV1(
366
245
  }
367
246
 
368
247
  /**
369
- * The content address of every binding baked into an isolate's `env`: User,
370
- * Bot, and Composition generation from `IDENTITY` and the `CAPABILITIES` props,
371
- * plus the User-enabled capability set. A loader id is served from cache, so
372
- * any change must produce a different digest or a stale isolate will answer
373
- * under the wrong identity or authority. Identical artifacts share an isolate
374
- * only when all of these binding inputs are identical (AGENTS.md Package
375
- * composition; ADR 0019).
248
+ * The content address of the bindings baked into a loaded isolate's env.
249
+ *
250
+ * Connection order is irrelevant; ids and generations are the authority
251
+ * identity. User, Bot, the resolved model binding, and the pinned Composition
252
+ * generation complete the digest. Package id is deliberately absent: two
253
+ * Packages of one Bot receive the same authority projection.
376
254
  */
377
255
  export async function isolateBindingDigestV1(input: {
378
256
  userId: string;
379
257
  botId: string;
380
- generationId: string;
381
- capabilities: readonly IsolateCapabilityV1[];
258
+ connections: readonly Pick<
259
+ IsolateConnectionV1,
260
+ "connectionId" | "generation"
261
+ >[];
262
+ model?: IsolateModelBindingV1;
263
+ compositionGenerationId: string;
382
264
  }): Promise<string> {
383
- const ordered = [...input.capabilities]
384
- .map((capability) => ({
385
- packageId: capability.packageId,
386
- capabilityId: capability.capabilityId,
387
- kind: capability.kind,
388
- connectionId: capability.connectionId ?? null,
389
- }))
390
- .sort(
391
- (left, right) =>
392
- compareIsolateIdentifierV1(left.packageId, right.packageId) ||
393
- compareIsolateIdentifierV1(left.capabilityId, right.capabilityId) ||
394
- compareIsolateIdentifierV1(left.kind, right.kind) ||
395
- compareIsolateIdentifierV1(
396
- left.connectionId ?? "",
397
- right.connectionId ?? "",
398
- ),
399
- );
265
+ const connections = [...input.connections]
266
+ .map(({ connectionId, generation }) => ({ connectionId, generation }))
267
+ .sort((left, right) => left.connectionId.localeCompare(right.connectionId));
400
268
  return await sha256Hex(
401
269
  JSON.stringify({
402
270
  userId: input.userId,
403
271
  botId: input.botId,
404
- generationId: input.generationId,
405
- ordered,
272
+ compositionGenerationId: input.compositionGenerationId,
273
+ connections,
274
+ model: input.model ?? null,
406
275
  }),
407
276
  );
408
277
  }
409
278
 
410
- function compareIsolateIdentifierV1(left: string, right: string): number {
411
- return left < right ? -1 : left > right ? 1 : 0;
412
- }
413
-
414
279
  async function sha256Hex(value: string): Promise<string> {
415
280
  const digest = await crypto.subtle.digest(
416
281
  "SHA-256",