@frockbot/plugin-shell 0.0.0 → 0.1.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.
- package/frockbot.json +68 -0
- package/package.json +87 -6
- package/src/agent.test.ts +372 -0
- package/src/agent.ts +335 -0
- package/src/approvals.test.ts +224 -0
- package/src/approvals.ts +530 -0
- package/src/backend-assignment.test.ts +161 -0
- package/src/backend-assignment.ts +274 -0
- package/src/backend-authoring.test.ts +518 -0
- package/src/backend-authoring.ts +531 -0
- package/src/backend-bot-identity.test.ts +215 -0
- package/src/backend-completion.test.ts +289 -0
- package/src/backend-completion.ts +95 -0
- package/src/backend-composition.ts +242 -0
- package/src/backend-computer.ts +76 -0
- package/src/backend-configuration.test.ts +1757 -0
- package/src/backend-contracts.test.ts +189 -0
- package/src/backend-contracts.ts +44 -0
- package/src/backend-debug.test.ts +202 -0
- package/src/backend-execution.ts +55 -0
- package/src/backend-flock.ts +96 -0
- package/src/backend-image.test.ts +115 -0
- package/src/backend-image.ts +180 -0
- package/src/backend-isolate.test.ts +238 -0
- package/src/backend-isolate.ts +409 -0
- package/src/backend-machine.ts +144 -0
- package/src/backend-memory.ts +89 -0
- package/src/backend-recovery-integration.test.ts +1575 -0
- package/src/backend-recovery.ts +106 -0
- package/src/backend-routines.ts +375 -0
- package/src/backend-runner.ts +251 -0
- package/src/backend-skills.test.ts +126 -0
- package/src/backend-skills.ts +198 -0
- package/src/backend-stop.test.ts +356 -0
- package/src/backend-subagents.ts +459 -0
- package/src/backend.ts +6035 -0
- package/src/client/FrockBotApp.vue +1026 -0
- package/src/client/SendPayloadView.vue +337 -0
- package/src/client/composer-draft.test.ts +31 -0
- package/src/client/composer-draft.ts +35 -0
- package/src/client/cordis-client-shim.d.ts +15 -0
- package/src/client/index.test.ts +2548 -0
- package/src/client/index.ts +2346 -0
- package/src/client/model-presentation.test.ts +35 -0
- package/src/client/model-presentation.ts +19 -0
- package/src/client/notify.test.ts +89 -0
- package/src/client/notify.ts +101 -0
- package/src/client/skill-invocation.test.ts +143 -0
- package/src/client/skill-invocation.ts +175 -0
- package/src/client/styles.css +1043 -0
- package/src/composition-views.ts +118 -0
- package/src/debug-protocol.test.ts +80 -0
- package/src/debug-protocol.ts +165 -0
- package/src/env.d.ts +10 -0
- package/src/history.test.ts +163 -0
- package/src/history.ts +108 -0
- package/src/host.ts +20 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/run-cursor.ts +28 -0
- package/src/run-protocol.test.ts +1281 -0
- package/src/run-protocol.ts +1417 -0
- package/src/settings-links.test.ts +106 -0
- package/src/settings-links.ts +289 -0
- package/src/shared.ts +338 -0
- package/src/skill-protocol.ts +117 -0
- package/src/terminal-records.test.ts +217 -0
- package/src/terminal-records.ts +150 -0
- package/src/unread.test.ts +362 -0
- package/src/unread.ts +675 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +32 -0
- package/README.md +0 -3
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// The Bot Durable Object half of the isolate capability boundary.
|
|
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.
|
|
10
|
+
import type {
|
|
11
|
+
IsolateAuthorityRequestV1,
|
|
12
|
+
IsolateCapabilityDescriptorV1,
|
|
13
|
+
IsolateModelInvocationV1,
|
|
14
|
+
IsolatePendingDecisionV1,
|
|
15
|
+
LlmStreamEvent,
|
|
16
|
+
NormalizedModelRequest,
|
|
17
|
+
} from "@frockbot/kernel-contracts";
|
|
18
|
+
import {
|
|
19
|
+
decodeIsolateAuthorityRequestV1,
|
|
20
|
+
encodeIsolateModelEventLineV1,
|
|
21
|
+
} from "@frockbot/kernel-contracts";
|
|
22
|
+
import type { BotIsolateArtifactStore } from "@frockbot/kernel-composition/isolate";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The compatibility date every Bot isolate is loaded with. Pinned beside the
|
|
26
|
+
* gateway Worker's own so Bot-authored code cannot outrun the kernel wrapper.
|
|
27
|
+
*/
|
|
28
|
+
export const BOT_ISOLATE_COMPATIBILITY_DATE = "2026-08-27";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What travels in `ctx.exports.BotCapabilities({ props })`. Structured-clonable
|
|
32
|
+
* by necessity: props cross into a loopback service binding.
|
|
33
|
+
*/
|
|
34
|
+
export interface BotCapabilitiesPropsV1 {
|
|
35
|
+
userId: string;
|
|
36
|
+
botId: string;
|
|
37
|
+
generationId: string;
|
|
38
|
+
packageId: string;
|
|
39
|
+
/** Already resolved and filtered to enabled Assignments by the authority. */
|
|
40
|
+
assignments: IsolateAssignmentV1[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const ISOLATE_DECISION_PREFIX = "isolate:decision:";
|
|
44
|
+
export const ISOLATE_MODEL_REQUEST_PREFIX = "isolate:model-request:";
|
|
45
|
+
|
|
46
|
+
/** A durable record that the Bot asked for authority it does not hold. */
|
|
47
|
+
export interface IsolatePendingAuthorityDecisionV1 {
|
|
48
|
+
schemaVersion: 1;
|
|
49
|
+
decisionId: string;
|
|
50
|
+
botId: string;
|
|
51
|
+
packageId: string;
|
|
52
|
+
generationId: string;
|
|
53
|
+
capabilityId: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
requestedAt: string;
|
|
56
|
+
status: "pending";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The intent recorded before a Bot-authored adapter's model call is forwarded. */
|
|
60
|
+
export interface IsolateModelRequestRecordV1 {
|
|
61
|
+
schemaVersion: 1;
|
|
62
|
+
/** Minted by this Durable Object; the record is keyed by it. */
|
|
63
|
+
recordId: string;
|
|
64
|
+
/** The Bot's own correlation id. Bounded, and never a storage key. */
|
|
65
|
+
requestId: string;
|
|
66
|
+
botId: string;
|
|
67
|
+
packageId: string;
|
|
68
|
+
generationId: string;
|
|
69
|
+
capabilityId: string;
|
|
70
|
+
request: NormalizedModelRequest;
|
|
71
|
+
recordedAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The narrow storage surface this module needs from the Durable Object. */
|
|
75
|
+
export interface IsolateCapabilityStore {
|
|
76
|
+
put(key: string, value: unknown): Promise<void>;
|
|
77
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
78
|
+
list<T>(options: { prefix: string }): Promise<Map<string, T>>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One enabled Assignment, already resolved the way `plugin-shell` resolves them. */
|
|
82
|
+
export interface IsolateAssignmentV1 {
|
|
83
|
+
assignmentId: string;
|
|
84
|
+
packageId: string;
|
|
85
|
+
capabilityId: string;
|
|
86
|
+
kind: IsolateCapabilityDescriptorV1["kind"];
|
|
87
|
+
connectionId?: string;
|
|
88
|
+
providerModelId?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The Bot's durable model binding, resolved by the authority from the Bot's
|
|
93
|
+
* own configuration and the User's Connection — never from anything the Bot
|
|
94
|
+
* supplied. An `invokeModel` request is authorized only when it names exactly
|
|
95
|
+
* this provider and this model, and it is forwarded carrying exactly this
|
|
96
|
+
* binding.
|
|
97
|
+
*/
|
|
98
|
+
export interface IsolateModelBindingV1 {
|
|
99
|
+
assignmentId: string;
|
|
100
|
+
packageId: string;
|
|
101
|
+
capabilityId: string;
|
|
102
|
+
connectionId: string;
|
|
103
|
+
provider: string;
|
|
104
|
+
providerModelId: string;
|
|
105
|
+
connectionGeneration?: string;
|
|
106
|
+
catalogGeneration?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The bound Bot-supplied correlation id: a field, never a key, and never unbounded. */
|
|
110
|
+
export const MAX_ISOLATE_REQUEST_ID = 256;
|
|
111
|
+
|
|
112
|
+
export interface IsolateModelPath {
|
|
113
|
+
/** Streams through the mounted provider Plugin; the lease is taken inside it. */
|
|
114
|
+
stream(
|
|
115
|
+
request: NormalizedModelRequest,
|
|
116
|
+
signal: AbortSignal,
|
|
117
|
+
): AsyncIterable<LlmStreamEvent>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface IsolateCapabilityHostOptions {
|
|
121
|
+
storage: IsolateCapabilityStore;
|
|
122
|
+
botId: string;
|
|
123
|
+
packageId: string;
|
|
124
|
+
generationId: string;
|
|
125
|
+
/** Assignment-derived and nothing else. */
|
|
126
|
+
assignments: readonly IsolateAssignmentV1[];
|
|
127
|
+
/**
|
|
128
|
+
* The one model binding this Bot durably holds, or absent when it holds
|
|
129
|
+
* none. Absent means every model request is a pending decision.
|
|
130
|
+
*/
|
|
131
|
+
modelBinding?: IsolateModelBindingV1;
|
|
132
|
+
/** Absent when the Bot has no enabled model Assignment at all. */
|
|
133
|
+
modelPath?: IsolateModelPath;
|
|
134
|
+
now?(): Date;
|
|
135
|
+
newId?(): string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface IsolateCapabilityHost {
|
|
139
|
+
list(): Promise<IsolateCapabilityDescriptorV1[]>;
|
|
140
|
+
requestAuthority(request: unknown): Promise<IsolatePendingDecisionV1>;
|
|
141
|
+
invokeModel(
|
|
142
|
+
request: NormalizedModelRequest,
|
|
143
|
+
): Promise<IsolateModelInvocationV1>;
|
|
144
|
+
pendingDecisions(): Promise<IsolatePendingAuthorityDecisionV1[]>;
|
|
145
|
+
recordedModelRequests(): Promise<IsolateModelRequestRecordV1[]>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The enabled model Assignment that can serve this request, if any.
|
|
150
|
+
*
|
|
151
|
+
* An Assignment authorizes exactly one Package, one Connection, and one
|
|
152
|
+
* provider model: the Bot's durable binding. A request naming any other
|
|
153
|
+
* provider or model resolves to nothing, whatever the Bot claims about it —
|
|
154
|
+
* the Bot-supplied `modelBinding` is never read here or anywhere downstream.
|
|
155
|
+
*/
|
|
156
|
+
export function matchingModelAssignmentV1(
|
|
157
|
+
assignments: readonly IsolateAssignmentV1[],
|
|
158
|
+
binding: IsolateModelBindingV1 | undefined,
|
|
159
|
+
request: NormalizedModelRequest,
|
|
160
|
+
): IsolateAssignmentV1 | undefined {
|
|
161
|
+
if (!binding) return undefined;
|
|
162
|
+
if (
|
|
163
|
+
request.provider !== binding.provider ||
|
|
164
|
+
request.model !== binding.providerModelId
|
|
165
|
+
) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
return assignments.find(
|
|
169
|
+
(assignment) =>
|
|
170
|
+
assignment.kind === "model" &&
|
|
171
|
+
assignment.assignmentId === binding.assignmentId &&
|
|
172
|
+
assignment.packageId === binding.packageId &&
|
|
173
|
+
assignment.capabilityId === binding.capabilityId &&
|
|
174
|
+
assignment.connectionId === binding.connectionId &&
|
|
175
|
+
assignment.providerModelId === binding.providerModelId,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createIsolateCapabilityHost(
|
|
180
|
+
options: IsolateCapabilityHostOptions,
|
|
181
|
+
): IsolateCapabilityHost {
|
|
182
|
+
const now = options.now ?? (() => new Date());
|
|
183
|
+
const newId = options.newId ?? (() => crypto.randomUUID());
|
|
184
|
+
|
|
185
|
+
async function recordDecision(
|
|
186
|
+
capabilityId: string,
|
|
187
|
+
reason: string,
|
|
188
|
+
): Promise<IsolatePendingDecisionV1> {
|
|
189
|
+
const decisionId = `decision-${newId()}`;
|
|
190
|
+
const record: IsolatePendingAuthorityDecisionV1 = {
|
|
191
|
+
schemaVersion: 1,
|
|
192
|
+
decisionId,
|
|
193
|
+
botId: options.botId,
|
|
194
|
+
packageId: options.packageId,
|
|
195
|
+
generationId: options.generationId,
|
|
196
|
+
capabilityId,
|
|
197
|
+
reason,
|
|
198
|
+
requestedAt: now().toISOString(),
|
|
199
|
+
status: "pending",
|
|
200
|
+
};
|
|
201
|
+
await options.storage.put(
|
|
202
|
+
`${ISOLATE_DECISION_PREFIX}${decisionId}`,
|
|
203
|
+
record,
|
|
204
|
+
);
|
|
205
|
+
return { status: "pending-user-decision", decisionId };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
list(): Promise<IsolateCapabilityDescriptorV1[]> {
|
|
210
|
+
return Promise.resolve(
|
|
211
|
+
options.assignments.map((assignment) => ({
|
|
212
|
+
capabilityId: assignment.capabilityId,
|
|
213
|
+
kind: assignment.kind,
|
|
214
|
+
})),
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async requestAuthority(
|
|
219
|
+
request: unknown,
|
|
220
|
+
): Promise<IsolatePendingDecisionV1> {
|
|
221
|
+
const decoded: IsolateAuthorityRequestV1 =
|
|
222
|
+
decodeIsolateAuthorityRequestV1(request);
|
|
223
|
+
// Self-modification never widens authority, even when the capability is
|
|
224
|
+
// already assigned: the answer is a decision the User makes.
|
|
225
|
+
return await recordDecision(decoded.capabilityId, decoded.reason);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async invokeModel(
|
|
229
|
+
request: NormalizedModelRequest,
|
|
230
|
+
): Promise<IsolateModelInvocationV1> {
|
|
231
|
+
if (request.requestId.length > MAX_ISOLATE_REQUEST_ID) {
|
|
232
|
+
throw new Error("isolate model request requestId is not bounded");
|
|
233
|
+
}
|
|
234
|
+
const binding = options.modelBinding;
|
|
235
|
+
const assignment = matchingModelAssignmentV1(
|
|
236
|
+
options.assignments,
|
|
237
|
+
binding,
|
|
238
|
+
request,
|
|
239
|
+
);
|
|
240
|
+
if (!assignment || !binding || !options.modelPath) {
|
|
241
|
+
return await recordDecision(
|
|
242
|
+
`models:${request.provider}:${request.model}`,
|
|
243
|
+
`Bot Package "${options.packageId}" asked to invoke a model with no matching enabled Assignment`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
// The binding the provider path receives is the authority's, never the
|
|
247
|
+
// Bot's: a Bot-composed request carries no Connection authority.
|
|
248
|
+
const forwarded: NormalizedModelRequest = {
|
|
249
|
+
...structuredClone(request),
|
|
250
|
+
modelBinding: {
|
|
251
|
+
connectionId: binding.connectionId,
|
|
252
|
+
...(binding.connectionGeneration
|
|
253
|
+
? { connectionGeneration: binding.connectionGeneration }
|
|
254
|
+
: {}),
|
|
255
|
+
...(binding.catalogGeneration
|
|
256
|
+
? { catalogGeneration: binding.catalogGeneration }
|
|
257
|
+
: {}),
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
// Record the exact normalized request before forwarding; the provider
|
|
261
|
+
// path takes the credential lease on the way through. The record is
|
|
262
|
+
// keyed by an id this authority mints, so a Bot cannot overwrite one of
|
|
263
|
+
// its own earlier records by reusing a `requestId`.
|
|
264
|
+
const recordId = `model-request-${newId()}`;
|
|
265
|
+
const record: IsolateModelRequestRecordV1 = {
|
|
266
|
+
schemaVersion: 1,
|
|
267
|
+
recordId,
|
|
268
|
+
requestId: request.requestId,
|
|
269
|
+
botId: options.botId,
|
|
270
|
+
packageId: options.packageId,
|
|
271
|
+
generationId: options.generationId,
|
|
272
|
+
capabilityId: assignment.capabilityId,
|
|
273
|
+
request: forwarded,
|
|
274
|
+
recordedAt: now().toISOString(),
|
|
275
|
+
};
|
|
276
|
+
await options.storage.put(
|
|
277
|
+
`${ISOLATE_MODEL_REQUEST_PREFIX}${recordId}`,
|
|
278
|
+
record,
|
|
279
|
+
);
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
const events = options.modelPath.stream(forwarded, controller.signal);
|
|
282
|
+
return {
|
|
283
|
+
status: "streaming",
|
|
284
|
+
requestId: request.requestId,
|
|
285
|
+
events: isolateModelEventStreamV1(events, controller),
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
async pendingDecisions(): Promise<IsolatePendingAuthorityDecisionV1[]> {
|
|
290
|
+
const stored =
|
|
291
|
+
await options.storage.list<IsolatePendingAuthorityDecisionV1>({
|
|
292
|
+
prefix: ISOLATE_DECISION_PREFIX,
|
|
293
|
+
});
|
|
294
|
+
return [...stored.values()];
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
async recordedModelRequests(): Promise<IsolateModelRequestRecordV1[]> {
|
|
298
|
+
const stored = await options.storage.list<IsolateModelRequestRecordV1>({
|
|
299
|
+
prefix: ISOLATE_MODEL_REQUEST_PREFIX,
|
|
300
|
+
});
|
|
301
|
+
return [...stored.values()];
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Model events cross the isolate boundary as an NDJSON byte stream. A
|
|
308
|
+
* `ReadableStream` of JavaScript objects is not transferable over workerd RPC;
|
|
309
|
+
* a byte stream is, so the kernel encodes here and the generated wrapper
|
|
310
|
+
* decodes on the far side.
|
|
311
|
+
*/
|
|
312
|
+
export const ISOLATE_MODEL_FAILURE_MESSAGE =
|
|
313
|
+
"the model provider did not complete this request";
|
|
314
|
+
|
|
315
|
+
export function isolateModelEventStreamV1(
|
|
316
|
+
events: AsyncIterable<LlmStreamEvent>,
|
|
317
|
+
controller?: AbortController,
|
|
318
|
+
): ReadableStream<Uint8Array> {
|
|
319
|
+
const encoder = new TextEncoder();
|
|
320
|
+
const iterator = events[Symbol.asyncIterator]();
|
|
321
|
+
return new ReadableStream<Uint8Array>({
|
|
322
|
+
async pull(stream) {
|
|
323
|
+
try {
|
|
324
|
+
const next = await iterator.next();
|
|
325
|
+
if (next.done) {
|
|
326
|
+
stream.close();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
stream.enqueue(
|
|
330
|
+
encoder.encode(encodeIsolateModelEventLineV1(next.value)),
|
|
331
|
+
);
|
|
332
|
+
} catch {
|
|
333
|
+
// Provider errors are normalized before they cross into Bot code: a
|
|
334
|
+
// raw provider message can name endpoints, account state, or the
|
|
335
|
+
// credential that failed. The Bot learns that the request did not
|
|
336
|
+
// complete and nothing else; the durable record and the provider
|
|
337
|
+
// Plugin keep the detail.
|
|
338
|
+
stream.error(new Error(ISOLATE_MODEL_FAILURE_MESSAGE));
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
cancel(reason) {
|
|
342
|
+
controller?.abort(reason);
|
|
343
|
+
return iterator.return?.(undefined).then(() => undefined);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The content address of the bindings an isolate is loaded with: its
|
|
350
|
+
* Assignments and the Composition generation whose `CAPABILITIES` stub is
|
|
351
|
+
* baked into its `env`. A loader id is served from cache, so a Bot whose
|
|
352
|
+
* Assignments change must get a different isolate rather than one that keeps a
|
|
353
|
+
* revoked binding — and a new generation must get a different isolate rather
|
|
354
|
+
* than one whose `env` still names the generation it was first loaded under.
|
|
355
|
+
* Both are bindings the isolate was granted, so both belong in this digest and
|
|
356
|
+
* the loader id stays derived from the artifact set and the binding digest
|
|
357
|
+
* alone.
|
|
358
|
+
*/
|
|
359
|
+
export async function isolateBindingDigestV1(
|
|
360
|
+
assignments: readonly IsolateAssignmentV1[],
|
|
361
|
+
generationId: string,
|
|
362
|
+
): Promise<string> {
|
|
363
|
+
const ordered = [...assignments]
|
|
364
|
+
.map((assignment) => ({
|
|
365
|
+
assignmentId: assignment.assignmentId,
|
|
366
|
+
packageId: assignment.packageId,
|
|
367
|
+
capabilityId: assignment.capabilityId,
|
|
368
|
+
kind: assignment.kind,
|
|
369
|
+
connectionId: assignment.connectionId ?? null,
|
|
370
|
+
providerModelId: assignment.providerModelId ?? null,
|
|
371
|
+
}))
|
|
372
|
+
.sort((left, right) => left.assignmentId.localeCompare(right.assignmentId));
|
|
373
|
+
return await sha256Hex(JSON.stringify({ generationId, ordered }));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
377
|
+
const digest = await crypto.subtle.digest(
|
|
378
|
+
"SHA-256",
|
|
379
|
+
new TextEncoder().encode(value),
|
|
380
|
+
);
|
|
381
|
+
return [...new Uint8Array(digest)]
|
|
382
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
383
|
+
.join("");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Reads a Bot Package artifact from object storage and verifies its content
|
|
388
|
+
* address before a byte of it becomes code. Artifacts are immutable content,
|
|
389
|
+
* not state; the hash is the only thing that makes them safe to mount.
|
|
390
|
+
*/
|
|
391
|
+
export function createR2PackageArtifactStore(
|
|
392
|
+
bucket: R2Bucket,
|
|
393
|
+
): BotIsolateArtifactStore {
|
|
394
|
+
return {
|
|
395
|
+
async loadPackageArtifact(contentHash: string): Promise<string> {
|
|
396
|
+
const object = await bucket.get(`packages/${contentHash}.mjs`);
|
|
397
|
+
if (!object) {
|
|
398
|
+
throw new Error(`package artifact "${contentHash}" is missing`);
|
|
399
|
+
}
|
|
400
|
+
const module = await object.text();
|
|
401
|
+
if ((await sha256Hex(module)) !== contentHash) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`package artifact "${contentHash}" failed hash verification`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return module;
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The Bot Durable Object's side of the registered machine (register rows 48,
|
|
2
|
+
// 49).
|
|
3
|
+
//
|
|
4
|
+
// Three seams, and no rules: the rules are `plugin-user-machine`'s, and the
|
|
5
|
+
// authority for the registry and the queue is the User Durable Object's. What
|
|
6
|
+
// lives here is the wiring one admitted Turn needs —
|
|
7
|
+
//
|
|
8
|
+
// * the runtime host the machine tools are handed, carrying this Bot's own
|
|
9
|
+
// durable storage (where an intent record lives) and four closed-over calls
|
|
10
|
+
// into the User object;
|
|
11
|
+
// * the dispatch the approval settlement performs once a person has said yes.
|
|
12
|
+
//
|
|
13
|
+
// The Shell owns this file for the same reason it owns `backend-routines.ts`:
|
|
14
|
+
// it is the object that holds the approval record, the alarm that expires it,
|
|
15
|
+
// and the route a person answers on, so the hand-off from "decided" to "queued"
|
|
16
|
+
// has nowhere else it could honestly live.
|
|
17
|
+
import type {
|
|
18
|
+
MachineCommandResultV1,
|
|
19
|
+
MachineCommandV1,
|
|
20
|
+
MachineListViewV1,
|
|
21
|
+
} from "@frockbot/machine-protocol";
|
|
22
|
+
import type { MachineMessagesRuntimeHostV1 } from "@frockbot/plugin-machine-messages/agent";
|
|
23
|
+
import {
|
|
24
|
+
machineMessagesEnabledV1,
|
|
25
|
+
machineMessagesGateV1,
|
|
26
|
+
type MachineMessagesGateV1,
|
|
27
|
+
} from "@frockbot/plugin-machine-messages/gate";
|
|
28
|
+
import type {
|
|
29
|
+
MachineIntentStorageV1,
|
|
30
|
+
MachineRuntimeHostV1,
|
|
31
|
+
MachineWriterIdentityV1,
|
|
32
|
+
} from "@frockbot/plugin-user-machine/agent";
|
|
33
|
+
import {
|
|
34
|
+
dispatchMachineIntentV1,
|
|
35
|
+
type MachineDispatchAnswerV1,
|
|
36
|
+
} from "@frockbot/plugin-user-machine/approval";
|
|
37
|
+
import type { MachineIntentRecordV1 } from "@frockbot/plugin-user-machine/intent";
|
|
38
|
+
import type { MachineTargetViewV1 } from "@frockbot/plugin-user-machine/target";
|
|
39
|
+
|
|
40
|
+
/** The User Durable Object, as this Bot is allowed to see its machines. */
|
|
41
|
+
export interface BotMachineSeamV1 {
|
|
42
|
+
list(): Promise<MachineListViewV1>;
|
|
43
|
+
describeTarget(machineId: string): Promise<MachineTargetViewV1>;
|
|
44
|
+
readResult(commandId: string): Promise<MachineCommandResultV1 | undefined>;
|
|
45
|
+
dispatch(command: MachineCommandV1): Promise<MachineDispatchAnswerV1>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The Turn an intent record is attributed to. */
|
|
49
|
+
export interface BotMachineTurnV1 {
|
|
50
|
+
sessionId: string;
|
|
51
|
+
turnId: string;
|
|
52
|
+
runId: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The runtime host for one admitted Turn.
|
|
57
|
+
*
|
|
58
|
+
* `storage` is the Bot's own, because an intent is Bot-scoped durable state:
|
|
59
|
+
* it is what the settlement reads back from an `approvalId` to know what a
|
|
60
|
+
* person actually approved.
|
|
61
|
+
*/
|
|
62
|
+
export function createBotMachineHost(
|
|
63
|
+
identity: { botId: string },
|
|
64
|
+
turn: BotMachineTurnV1,
|
|
65
|
+
storage: MachineIntentStorageV1,
|
|
66
|
+
seam: BotMachineSeamV1,
|
|
67
|
+
): MachineRuntimeHostV1 {
|
|
68
|
+
return {
|
|
69
|
+
botId: identity.botId,
|
|
70
|
+
writer: {
|
|
71
|
+
sessionId: turn.sessionId,
|
|
72
|
+
turnId: turn.turnId,
|
|
73
|
+
runId: turn.runId,
|
|
74
|
+
},
|
|
75
|
+
storage,
|
|
76
|
+
list: () => seam.list(),
|
|
77
|
+
describeTarget: (machineId) => seam.describeTarget(machineId),
|
|
78
|
+
readResult: (commandId) => seam.readResult(commandId),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Put an approved command on its machine's queue, after the decision has
|
|
84
|
+
* committed.
|
|
85
|
+
*
|
|
86
|
+
* Outside the settling transaction on purpose: a cross-Durable-Object call
|
|
87
|
+
* inside one would make its atomicity a lie. It does not need to be inside
|
|
88
|
+
* one — the dispatch is idempotent on `commandId`, which is the Turn's own
|
|
89
|
+
* `effectId`, so a crash between the commit and this call is a retry and never
|
|
90
|
+
* a second command on somebody's laptop.
|
|
91
|
+
*/
|
|
92
|
+
export async function dispatchApprovedMachineIntentV1(
|
|
93
|
+
storage: MachineIntentStorageV1,
|
|
94
|
+
intent: MachineIntentRecordV1,
|
|
95
|
+
seam: Pick<BotMachineSeamV1, "dispatch">,
|
|
96
|
+
now: () => string = () => new Date().toISOString(),
|
|
97
|
+
): Promise<MachineIntentRecordV1> {
|
|
98
|
+
return dispatchMachineIntentV1(
|
|
99
|
+
storage,
|
|
100
|
+
intent,
|
|
101
|
+
(command) => seam.dispatch(command),
|
|
102
|
+
now(),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Row 57g's gate, answered for one Turn.
|
|
108
|
+
*
|
|
109
|
+
* Two facts and one read. The setting is already in hand — it came from the
|
|
110
|
+
* same User configuration the rest of this Composition was resolved from — and
|
|
111
|
+
* the registry is asked for only when it is on, so a deployment with the
|
|
112
|
+
* feature off pays nothing for it.
|
|
113
|
+
*
|
|
114
|
+
* `undefined` means the tools are not mounted: off, or no connected macOS
|
|
115
|
+
* machine reporting `messages`. A Bot never sees a Messages tool it could only
|
|
116
|
+
* be refused by.
|
|
117
|
+
*/
|
|
118
|
+
export async function resolveBotMachineMessagesGateV1(
|
|
119
|
+
settings: Readonly<Record<string, string | number | boolean>> | undefined,
|
|
120
|
+
list: () => Promise<MachineListViewV1>,
|
|
121
|
+
): Promise<MachineMessagesGateV1> {
|
|
122
|
+
if (!machineMessagesEnabledV1(settings)) return { status: "off" };
|
|
123
|
+
const view = await list();
|
|
124
|
+
return machineMessagesGateV1({ enabled: true, machines: view.machines });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The Messages host for one admitted Turn.
|
|
129
|
+
*
|
|
130
|
+
* It is the machine host plus `dispatch`, and it exists only because the six
|
|
131
|
+
* approval-exempt reads have no settlement to ride: `machine_exec` is queued by
|
|
132
|
+
* the approval settlement, and a read has no approval. Nothing else widens —
|
|
133
|
+
* the Package cannot enrol, revoke, or reach any machine this User does not
|
|
134
|
+
* own, because the seam it is handed is the same one the control tools use.
|
|
135
|
+
*/
|
|
136
|
+
export function createBotMachineMessagesHost(
|
|
137
|
+
machines: MachineRuntimeHostV1 & { writer: MachineWriterIdentityV1 },
|
|
138
|
+
seam: Pick<BotMachineSeamV1, "dispatch">,
|
|
139
|
+
): MachineMessagesRuntimeHostV1 {
|
|
140
|
+
return {
|
|
141
|
+
machines,
|
|
142
|
+
dispatch: (command) => seam.dispatch(command),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The Bot Durable Object's half of the Memory seam.
|
|
2
|
+
//
|
|
3
|
+
// The Memory Package reads and writes Memory roots through a `MemoryStore`
|
|
4
|
+
// over `WorkspaceFilesV1`. This module decides, for one admitted Turn, whether
|
|
5
|
+
// such a surface exists, what provenance a write records, and where Project
|
|
6
|
+
// membership is kept. It implements none of those.
|
|
7
|
+
//
|
|
8
|
+
// HIBERNATION. "The Agent loop, Memory, Skills, Package composition, and
|
|
9
|
+
// Routines function correctly while the Computer is hibernated and do not wake
|
|
10
|
+
// it." Nothing here reaches the Computer registry, a Computer provider, or a
|
|
11
|
+
// Sprite. The surface handed to the Memory Package is a binding on the Durable
|
|
12
|
+
// Object's environment, backed by object storage under ADR 0013; whether a
|
|
13
|
+
// Computer host happens to be running changes nothing above this line.
|
|
14
|
+
//
|
|
15
|
+
// SEAM. `MEMORY_WORKSPACE_FILES` is bound in production by
|
|
16
|
+
// `apps/cloudflare/src/bot-state.ts`: `WorkspaceFilesV1` with `surface:
|
|
17
|
+
// "memory"`, so it serves Memory roots and refuses every other, with shared
|
|
18
|
+
// roots' generations recorded in the *User* Durable Object and the Bot's own
|
|
19
|
+
// root in the Bot's. A host that binds nothing gets `undefined` and the Memory
|
|
20
|
+
// Package is then not mounted at all: a Turn with no readable Memory root
|
|
21
|
+
// injects no Memory, visibly, rather than inventing a second store.
|
|
22
|
+
import type { WorkspaceFilesV1 } from "@frockbot/kernel-contracts";
|
|
23
|
+
import type {
|
|
24
|
+
MemoryProjectsV1,
|
|
25
|
+
MemoryRuntimeHostV1,
|
|
26
|
+
} from "@frockbot/plugin-memory/agent";
|
|
27
|
+
import { MemoryStore } from "@frockbot/plugin-memory/store";
|
|
28
|
+
|
|
29
|
+
/** The Bot and User whose Memory a Turn may read and write. */
|
|
30
|
+
export interface BotMemoryIdentity {
|
|
31
|
+
userId: string;
|
|
32
|
+
botId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The run, Turn, and Session a Memory write records as its provenance. */
|
|
36
|
+
export interface BotMemoryTurn {
|
|
37
|
+
runId: string;
|
|
38
|
+
turnId: string;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The narrow slice of the Durable Object environment this module reads. Named
|
|
44
|
+
* as its own type so each binding's absence is a typed state, not a cast.
|
|
45
|
+
*/
|
|
46
|
+
export interface BotMemoryEnv {
|
|
47
|
+
/** `WorkspaceFilesV1` with the Memory surface. Absent in a host with no bucket. */
|
|
48
|
+
MEMORY_WORKSPACE_FILES?: WorkspaceFilesV1;
|
|
49
|
+
/** The durable Project authority, in the User Durable Object. */
|
|
50
|
+
MEMORY_PROJECTS?: MemoryProjectsV1;
|
|
51
|
+
/** Display names per Bot id, for the `[via …]` tag on a shared fact. */
|
|
52
|
+
MEMORY_BOT_NAMES?: Readonly<Record<string, string>>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The Memory seam one admitted Turn runs under, or `undefined` when the Bot's
|
|
57
|
+
* Memory surface is unavailable.
|
|
58
|
+
*/
|
|
59
|
+
export function createBotMemoryHost(
|
|
60
|
+
identity: BotMemoryIdentity,
|
|
61
|
+
turn: BotMemoryTurn,
|
|
62
|
+
env: object,
|
|
63
|
+
): MemoryRuntimeHostV1 | undefined {
|
|
64
|
+
// SAFETY: the Memory file surface is constructed onto the Durable Object
|
|
65
|
+
// environment rather than declared in the generated `Env`, because it is not
|
|
66
|
+
// a Worker binding. Absence is a supported state, not an error.
|
|
67
|
+
const bindings = env as BotMemoryEnv;
|
|
68
|
+
const files = bindings.MEMORY_WORKSPACE_FILES;
|
|
69
|
+
if (!files) return undefined;
|
|
70
|
+
const owner = { userId: identity.userId, botId: identity.botId };
|
|
71
|
+
return {
|
|
72
|
+
owner,
|
|
73
|
+
store: new MemoryStore({
|
|
74
|
+
files,
|
|
75
|
+
owner,
|
|
76
|
+
...(bindings.MEMORY_BOT_NAMES
|
|
77
|
+
? { botNames: bindings.MEMORY_BOT_NAMES }
|
|
78
|
+
: {}),
|
|
79
|
+
}),
|
|
80
|
+
// A Bot changes Memory only inside a Turn whose run, Turn and Session its
|
|
81
|
+
// provenance names — the same rule Skills and Package authoring follow.
|
|
82
|
+
writer: {
|
|
83
|
+
sessionId: turn.sessionId,
|
|
84
|
+
turnId: turn.turnId,
|
|
85
|
+
runId: turn.runId,
|
|
86
|
+
},
|
|
87
|
+
...(bindings.MEMORY_PROJECTS ? { projects: bindings.MEMORY_PROJECTS } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|