@frockbot/cloudflare 0.0.0 → 0.7.291
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/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
import {
|
|
2
|
+
foundationPackageCatalogV1,
|
|
3
|
+
FOUNDATION_PACKAGE_VERSION_V1,
|
|
4
|
+
} from "@frockbot/app/runtime";
|
|
5
|
+
import { BRAND_V1 } from "#brand";
|
|
6
|
+
import { COMPUTER_HOST_CAPABILITIES_V1 } from "./computer-host.js";
|
|
7
|
+
import {
|
|
8
|
+
decodeBotIdV1,
|
|
9
|
+
isApplicationDeploymentHash,
|
|
10
|
+
isRpcIdentifier,
|
|
11
|
+
} from "@frockbot/core/configuration";
|
|
12
|
+
import {
|
|
13
|
+
decodeClientNotificationAcknowledgementCommandV1,
|
|
14
|
+
type ClientNotificationAcknowledgementV1,
|
|
15
|
+
type ClientNotificationListV1,
|
|
16
|
+
decodeClientRunAdmissionFenceCommandV1,
|
|
17
|
+
decodeClientRunLookupQueryV1,
|
|
18
|
+
decodeClientRunListQueryV1,
|
|
19
|
+
parseExchangeCounterpartParamV1,
|
|
20
|
+
clientProtocolOfV1,
|
|
21
|
+
decodeClientRunStopCommandV1,
|
|
22
|
+
decodeClientTurnCommandV1,
|
|
23
|
+
RUN_ATTACHMENTS_PROTOCOL_V1,
|
|
24
|
+
withoutRunAttachmentsV1,
|
|
25
|
+
type ClientRunLookupQueryV1,
|
|
26
|
+
type ClientRunStopCommandV1,
|
|
27
|
+
type ClientTurnCommandV1,
|
|
28
|
+
type ClientTurnRefusalReasonV1,
|
|
29
|
+
type ClientTurnRefusalV1,
|
|
30
|
+
} from "@frockbot/app/shell/run-protocol";
|
|
31
|
+
import { decodeApprovalDecisionCommandV1 } from "@frockbot/app/shell/approvals";
|
|
32
|
+
import {
|
|
33
|
+
decodeSecretSubmitCommandV1,
|
|
34
|
+
isSecretRequestIdV1,
|
|
35
|
+
SecretDecodeError,
|
|
36
|
+
} from "@frockbot/app/secrets/shared";
|
|
37
|
+
import {
|
|
38
|
+
decodeCardActionCommandV1,
|
|
39
|
+
decodeCardSurfaceIdV1,
|
|
40
|
+
} from "@frockbot/app/shell/cards";
|
|
41
|
+
import { botTurnRefusalCodeV1 } from "@frockbot/core/durable";
|
|
42
|
+
import type { UserApplicationEnv } from "./contracts.js";
|
|
43
|
+
import { answeredEntryV1, entryFailureStatusV1 } from "./entry-boundary.js";
|
|
44
|
+
import { INSIGHTS_REPORT_ORIGIN, INSIGHTS_SCRIPT_ORIGIN } from "./insights.js";
|
|
45
|
+
import {
|
|
46
|
+
drainedAnswerV1,
|
|
47
|
+
isRequestTooLargeV1,
|
|
48
|
+
RequestTooLargeError,
|
|
49
|
+
TURN_BODY_MAX_BYTES_V1,
|
|
50
|
+
TURN_TOO_LONG_MESSAGE_V1,
|
|
51
|
+
turnBodyIsOversizedV1,
|
|
52
|
+
} from "./request-body.js";
|
|
53
|
+
import { whatsNewImageResponseV1 } from "@frockbot/app/whats-new";
|
|
54
|
+
|
|
55
|
+
declare const __FROCKBOT_FLUTTER_BUILD__: string;
|
|
56
|
+
declare const __FROCKBOT_CLIENT_ICON__: string;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The content-addressed prefix the Flutter client is served from.
|
|
60
|
+
*
|
|
61
|
+
* The payload is the Worker's own static assets, uploaded with the deploy and
|
|
62
|
+
* served straight from the edge; the artifact only names it. Every URL under
|
|
63
|
+
* the prefix carries the build hash, so the document is the one thing that
|
|
64
|
+
* changes when the client does.
|
|
65
|
+
*/
|
|
66
|
+
const FLUTTER_BASE =
|
|
67
|
+
typeof __FROCKBOT_FLUTTER_BUILD__ === "string"
|
|
68
|
+
? `/_flutter/${__FROCKBOT_FLUTTER_BUILD__}/`
|
|
69
|
+
: "/_flutter/development/";
|
|
70
|
+
// The site icon is a PNG, so it rides the artifact as base64 and is decoded
|
|
71
|
+
// once at module scope rather than on every request.
|
|
72
|
+
const APP_ICON = Uint8Array.from(
|
|
73
|
+
atob(
|
|
74
|
+
typeof __FROCKBOT_CLIENT_ICON__ === "string"
|
|
75
|
+
? __FROCKBOT_CLIENT_ICON__
|
|
76
|
+
: "",
|
|
77
|
+
),
|
|
78
|
+
(character) => character.charCodeAt(0),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/** The product's name as the document title carries it. */
|
|
82
|
+
const DOCUMENT_TITLE = BRAND_V1.productName
|
|
83
|
+
.replaceAll("&", "&")
|
|
84
|
+
.replaceAll("<", "<");
|
|
85
|
+
|
|
86
|
+
type HostedAuthModeV1 = "anonymous" | "better-auth" | "development";
|
|
87
|
+
|
|
88
|
+
function hostedAuthMode(request: Request): HostedAuthModeV1 {
|
|
89
|
+
const mode = request.headers.get("x-frockbot-auth-session-v1");
|
|
90
|
+
if (
|
|
91
|
+
mode !== "anonymous" &&
|
|
92
|
+
mode !== "better-auth" &&
|
|
93
|
+
mode !== "development"
|
|
94
|
+
) {
|
|
95
|
+
throw new Error("hosted auth session projection is invalid");
|
|
96
|
+
}
|
|
97
|
+
return mode;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hostedIsAdmin(request: Request): boolean {
|
|
101
|
+
const value = request.headers.get("x-frockbot-is-admin-v1");
|
|
102
|
+
if (value !== "true" && value !== "false") {
|
|
103
|
+
throw new Error("hosted admin projection is invalid");
|
|
104
|
+
}
|
|
105
|
+
return value === "true";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The `<body>` attributes the client reads before it has asked anything.
|
|
110
|
+
*
|
|
111
|
+
* A browser's session is a cookie it cannot see, so the account is stamped
|
|
112
|
+
* onto the document the Worker renders and the Flutter app adopts it on its
|
|
113
|
+
* first frame (`apps/native/packages/frockbot_client/lib/client/identity_web.dart`) rather than
|
|
114
|
+
* flashing the sign-in door at someone who is already signed in. The identity
|
|
115
|
+
* read still happens; this is what it confirms.
|
|
116
|
+
*/
|
|
117
|
+
export const HOSTED_EMBEDDED_BODY_ATTRIBUTES_V1 = [
|
|
118
|
+
"data-frockbot-user-id",
|
|
119
|
+
"data-frockbot-auth-mode",
|
|
120
|
+
"data-frockbot-is-admin",
|
|
121
|
+
] as const;
|
|
122
|
+
|
|
123
|
+
function appHtml(
|
|
124
|
+
userId: string,
|
|
125
|
+
applicationHash: string,
|
|
126
|
+
authMode: HostedAuthModeV1,
|
|
127
|
+
isAdmin: boolean,
|
|
128
|
+
): string {
|
|
129
|
+
if (!isRpcIdentifier(userId)) throw new Error("invalid user id");
|
|
130
|
+
if (!isApplicationDeploymentHash(applicationHash)) {
|
|
131
|
+
throw new Error("invalid application hash");
|
|
132
|
+
}
|
|
133
|
+
return `<!doctype html>
|
|
134
|
+
<html lang="en">
|
|
135
|
+
<head>
|
|
136
|
+
<meta charset="utf-8">
|
|
137
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
138
|
+
<meta name="frockbot-application" content="${applicationHash}">
|
|
139
|
+
<base href="${FLUTTER_BASE}">
|
|
140
|
+
<title>${DOCUMENT_TITLE}</title>
|
|
141
|
+
<link rel="icon" type="image/png" href="/favicon.ico">
|
|
142
|
+
<link rel="apple-touch-icon" href="/favicon.ico">
|
|
143
|
+
<meta name="color-scheme" content="dark">
|
|
144
|
+
<meta name="theme-color" content="#15151e">
|
|
145
|
+
<style>html,body{margin:0;height:100%;background:#15151e}</style>
|
|
146
|
+
</head>
|
|
147
|
+
<body data-frockbot-user-id="${userId}" data-frockbot-user-application="${applicationHash}" data-frockbot-auth-mode="${authMode}" data-frockbot-is-admin="${String(isAdmin)}">
|
|
148
|
+
<script src="${FLUTTER_BASE}flutter_bootstrap.js" async></script>
|
|
149
|
+
</body>
|
|
150
|
+
</html>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* What the app frames: a Plugin's pages, which are served from this origin
|
|
155
|
+
* but only under `/plugin-pages/` and each sandboxed by its own policy, and
|
|
156
|
+
* the Computer host's viewer origins.
|
|
157
|
+
*/
|
|
158
|
+
function frameSources(applicationUrl: URL): string {
|
|
159
|
+
return [
|
|
160
|
+
`${applicationUrl.origin}/plugin-pages/`,
|
|
161
|
+
...COMPUTER_HOST_CAPABILITIES_V1.viewerFrameOrigins,
|
|
162
|
+
].join(" ");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function withSecurityHeaders(
|
|
166
|
+
response: Response,
|
|
167
|
+
applicationUrl: URL,
|
|
168
|
+
): Response {
|
|
169
|
+
const secured = new Response(response.body, response);
|
|
170
|
+
secured.headers.set("x-content-type-options", "nosniff");
|
|
171
|
+
secured.headers.set("referrer-policy", "no-referrer");
|
|
172
|
+
secured.headers.set(
|
|
173
|
+
"content-security-policy",
|
|
174
|
+
// The app frames a Plugin's pages and the expanded Computer viewer.
|
|
175
|
+
//
|
|
176
|
+
// Cloudflare Insights is injected into every response by the zone itself,
|
|
177
|
+
// above this Worker, so the page loads it whether or not the policy allows
|
|
178
|
+
// it — and under `script-src 'self'` it was refused on every page load and
|
|
179
|
+
// logged a red console error for every User. Naming the beacon's origin is
|
|
180
|
+
// the honest fix: it is the deployment's own analytics, its script is
|
|
181
|
+
// fetched from one Cloudflare host and it reports to another. Removing it
|
|
182
|
+
// instead would mean turning the feature off in the zone, which the code
|
|
183
|
+
// cannot state or keep true.
|
|
184
|
+
//
|
|
185
|
+
// `script-src 'wasm-unsafe-eval'` and `style-src 'unsafe-inline'` are what
|
|
186
|
+
// the Flutter engine needs and neither is optional: CanvasKit instantiates
|
|
187
|
+
// WebAssembly, and the engine injects a `<style>` element to measure text.
|
|
188
|
+
// `base-uri 'self'` rather than `'none'` because the document sets a
|
|
189
|
+
// `<base href>` of its own to the content-addressed directory every engine
|
|
190
|
+
// URL is relative to.
|
|
191
|
+
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval' ${INSIGHTS_SCRIPT_ORIGIN}; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' ${INSIGHTS_REPORT_ORIGIN} ${applicationUrl.protocol === "https:" ? "wss:" : "ws:"}//${applicationUrl.host}; frame-src ${frameSources(applicationUrl)}; frame-ancestors 'none'; base-uri 'self'`,
|
|
192
|
+
);
|
|
193
|
+
return secured;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function jsonError(
|
|
197
|
+
status: number,
|
|
198
|
+
message: string,
|
|
199
|
+
options?: { definitive?: boolean },
|
|
200
|
+
): Response {
|
|
201
|
+
return Response.json(
|
|
202
|
+
{
|
|
203
|
+
error: message,
|
|
204
|
+
...(options?.definitive ? { definitive: true } : {}),
|
|
205
|
+
},
|
|
206
|
+
{ status },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A Bot-scoped call that failed, answered with the status the failure is owed.
|
|
212
|
+
*
|
|
213
|
+
* These routes check the registration once, at the top, and then talk to the
|
|
214
|
+
* Bot Durable Object — and a Bot can stop existing in between. Deleting a Bot
|
|
215
|
+
* makes that window real and routine: the panels of the Bot being looked at
|
|
216
|
+
* poll it, so a read is almost always in flight when the delete lands. Such a
|
|
217
|
+
* read is a 404 or a 410, not a server fault, and answering 500 made the
|
|
218
|
+
* client log an error for the correct answer to its own question.
|
|
219
|
+
*
|
|
220
|
+
* Only a failure the entry boundary names gets a status of its own; anything
|
|
221
|
+
* unrecognised is still this Worker's 500.
|
|
222
|
+
*/
|
|
223
|
+
function botFailure(error: unknown, fallback: string): Response {
|
|
224
|
+
return jsonError(
|
|
225
|
+
entryFailureStatusV1(error),
|
|
226
|
+
error instanceof Error ? error.message : fallback,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A Turn the Bot Durable Object declined to admit, told apart from one that
|
|
232
|
+
* broke.
|
|
233
|
+
*
|
|
234
|
+
* Admission refusals are ordinary, expected answers — the object is busy with
|
|
235
|
+
* a Turn this command did not ask to replace, or is holding an uncertain
|
|
236
|
+
* effect that has to be retrieved first, or the run was fenced. None of them
|
|
237
|
+
* is a server fault, and answering 500 made the client log a console error for
|
|
238
|
+
* something it should simply show the person. They are 409: the request was
|
|
239
|
+
* well formed and the Bot's current state refuses it.
|
|
240
|
+
*/
|
|
241
|
+
const TURN_ADMISSION_REFUSALS_V1: readonly {
|
|
242
|
+
match: RegExp;
|
|
243
|
+
reason: ClientTurnRefusalReasonV1;
|
|
244
|
+
}[] = [
|
|
245
|
+
{ match: /bot already has an active run/i, reason: "busy" },
|
|
246
|
+
{ match: /admission was fenced/i, reason: "fenced" },
|
|
247
|
+
{ match: /already (exists|completed)/i, reason: "duplicate" },
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
export type { ClientTurnRefusalReasonV1, ClientTurnRefusalV1 };
|
|
251
|
+
|
|
252
|
+
function turnRefusal(error: unknown): ClientTurnRefusalV1 | undefined {
|
|
253
|
+
const message = error instanceof Error ? error.message : "";
|
|
254
|
+
// The typed refusal first: the authority names its own reason, and the name
|
|
255
|
+
// is what survives the Durable Object RPC. The prose match stays as a
|
|
256
|
+
// fallback for refusals a Package still raises as plain errors.
|
|
257
|
+
const reason =
|
|
258
|
+
botTurnRefusalCodeV1(error) ??
|
|
259
|
+
TURN_ADMISSION_REFUSALS_V1.find((candidate) =>
|
|
260
|
+
candidate.match.test(message),
|
|
261
|
+
)?.reason;
|
|
262
|
+
if (!reason) return undefined;
|
|
263
|
+
return {
|
|
264
|
+
schemaVersion: 1,
|
|
265
|
+
status: "refused",
|
|
266
|
+
reason,
|
|
267
|
+
error: message,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function requireRegisteredBot(
|
|
272
|
+
env: UserApplicationEnv,
|
|
273
|
+
botId: string,
|
|
274
|
+
allowArchived = false,
|
|
275
|
+
): Promise<Response | undefined> {
|
|
276
|
+
try {
|
|
277
|
+
await env.BOT_STATE.assertRegistered({ schemaVersion: 1, botId });
|
|
278
|
+
return undefined;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (typeof error === "object" && error !== null && "name" in error) {
|
|
281
|
+
if (error.name === "BotNotFoundError")
|
|
282
|
+
return jsonError(
|
|
283
|
+
404,
|
|
284
|
+
error instanceof Error ? error.message : "Bot not found",
|
|
285
|
+
);
|
|
286
|
+
if (error.name === "BotArchivedError") {
|
|
287
|
+
if (allowArchived) return undefined;
|
|
288
|
+
return jsonError(
|
|
289
|
+
409,
|
|
290
|
+
error instanceof Error ? error.message : "Bot is archived",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (error.name === "BotDeletedError")
|
|
294
|
+
return jsonError(
|
|
295
|
+
410,
|
|
296
|
+
error instanceof Error ? error.message : "Bot is deleted",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return jsonError(
|
|
300
|
+
503,
|
|
301
|
+
error instanceof Error
|
|
302
|
+
? error.message
|
|
303
|
+
: "Bot registration is temporarily unavailable",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Read a send, refusing an oversized one before the body is touched.
|
|
310
|
+
*
|
|
311
|
+
* The refusal is typed rather than prose so the route can answer 413 with the
|
|
312
|
+
* sentence the composer shows, and so it is told apart from a body that simply
|
|
313
|
+
* would not parse. The gateway checks the same length one isolate earlier;
|
|
314
|
+
* this check is the one that holds however else a request reaches this Worker.
|
|
315
|
+
*/
|
|
316
|
+
async function readTurnCommand(request: Request): Promise<ClientTurnCommandV1> {
|
|
317
|
+
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
|
318
|
+
if (contentLength > TURN_BODY_MAX_BYTES_V1) {
|
|
319
|
+
throw new RequestTooLargeError();
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return decodeClientTurnCommandV1(await request.json());
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// The decoder's own bound on `text`. Same refusal, said the same way: a
|
|
325
|
+
// body under the wire limit can still carry a message over the text one.
|
|
326
|
+
if (
|
|
327
|
+
error instanceof Error &&
|
|
328
|
+
/turn command\.text\b.*\b(exceeds|too long|too large)/i.test(
|
|
329
|
+
error.message,
|
|
330
|
+
)
|
|
331
|
+
) {
|
|
332
|
+
throw new RequestTooLargeError();
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function createUserApplication() {
|
|
339
|
+
const route = createUserApplicationRoute();
|
|
340
|
+
/*
|
|
341
|
+
* The User application's own outermost wrapper.
|
|
342
|
+
*
|
|
343
|
+
* This Worker is loaded into its own isolate and receives its own `Request`,
|
|
344
|
+
* so the gateway's drain does nothing for it: an early return here — a 404
|
|
345
|
+
* on a POST, a body refused for its size — is exactly the shape that makes
|
|
346
|
+
* workerd tear the isolate down with "Can't read from request stream after
|
|
347
|
+
* response has been sent". Every answer passes through the drain, and the
|
|
348
|
+
* size refusal is given before any route runs so an oversized send is never
|
|
349
|
+
* parsed.
|
|
350
|
+
*/
|
|
351
|
+
return async (
|
|
352
|
+
request: Request,
|
|
353
|
+
env: UserApplicationEnv,
|
|
354
|
+
): Promise<Response> => {
|
|
355
|
+
let oversized = false;
|
|
356
|
+
try {
|
|
357
|
+
oversized = turnBodyIsOversizedV1(request, new URL(request.url));
|
|
358
|
+
} catch {
|
|
359
|
+
// An unparseable URL is the route's 400 to give, not this guard's.
|
|
360
|
+
}
|
|
361
|
+
if (oversized) {
|
|
362
|
+
return drainedAnswerV1(request, jsonError(413, TURN_TOO_LONG_MESSAGE_V1));
|
|
363
|
+
}
|
|
364
|
+
// This Worker's `fetch` is an entry point of its own: a route that throws
|
|
365
|
+
// has no caller left inside the isolate, and the log showed exactly that —
|
|
366
|
+
// `BotNotFoundError` five times in one window, then a refused Turn, then
|
|
367
|
+
// the process exiting. A Bot that is not there is a 404 the client can act
|
|
368
|
+
// on; anything else is a 500 that still carries a readable reason, and the
|
|
369
|
+
// isolate survives to answer the next request.
|
|
370
|
+
return drainedAnswerV1(
|
|
371
|
+
request,
|
|
372
|
+
await answeredEntryV1("request failed", () => route(request, env)),
|
|
373
|
+
);
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function createUserApplicationRoute() {
|
|
378
|
+
return async (
|
|
379
|
+
request: Request,
|
|
380
|
+
env: UserApplicationEnv,
|
|
381
|
+
): Promise<Response> => {
|
|
382
|
+
let url: URL;
|
|
383
|
+
try {
|
|
384
|
+
url = new URL(request.url);
|
|
385
|
+
} catch {
|
|
386
|
+
return jsonError(400, "invalid request URL");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
390
|
+
return withSecurityHeaders(
|
|
391
|
+
new Response(
|
|
392
|
+
appHtml(
|
|
393
|
+
env.DEPLOYMENT.userId,
|
|
394
|
+
env.DEPLOYMENT.applicationHash,
|
|
395
|
+
hostedAuthMode(request),
|
|
396
|
+
hostedIsAdmin(request),
|
|
397
|
+
),
|
|
398
|
+
{
|
|
399
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
400
|
+
},
|
|
401
|
+
),
|
|
402
|
+
url,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (request.method === "GET" && url.pathname === "/favicon.ico") {
|
|
406
|
+
// Browsers request `/favicon.ico` by convention even when a link element
|
|
407
|
+
// names it, so the site icon answers on that one path in its real type.
|
|
408
|
+
return withSecurityHeaders(
|
|
409
|
+
new Response(APP_ICON, {
|
|
410
|
+
headers: {
|
|
411
|
+
"content-type": "image/png",
|
|
412
|
+
"cache-control": "no-cache",
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
url,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (request.method === "GET" && BRAND_V1.whatsNew) {
|
|
419
|
+
const picture = whatsNewImageResponseV1(url.pathname);
|
|
420
|
+
if (picture) {
|
|
421
|
+
return withSecurityHeaders(picture, url);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (request.method === "GET" && url.pathname === "/app-manifest") {
|
|
425
|
+
return Response.json({
|
|
426
|
+
schemaVersion: 1,
|
|
427
|
+
deployment: env.DEPLOYMENT,
|
|
428
|
+
// The client needs model-provider facts even when a Package is
|
|
429
|
+
// platform-owned, so every Package is projected and each carries the
|
|
430
|
+
// ownership its definition declares; enablement surfaces omit those
|
|
431
|
+
// rows while model resolution still sees them.
|
|
432
|
+
packages: foundationPackageCatalogV1(BRAND_V1).entries.map((pkg) => ({
|
|
433
|
+
id: pkg.id,
|
|
434
|
+
displayName: pkg.displayName,
|
|
435
|
+
version: FOUNDATION_PACKAGE_VERSION_V1,
|
|
436
|
+
...(pkg.platformOwned ? { platformOwned: true } : {}),
|
|
437
|
+
...(pkg.settings ? { settings: pkg.settings } : {}),
|
|
438
|
+
...(pkg.capabilities ? { capabilities: pkg.capabilities } : {}),
|
|
439
|
+
...(pkg.connectionTypes
|
|
440
|
+
? { connectionTypes: pkg.connectionTypes }
|
|
441
|
+
: {}),
|
|
442
|
+
})),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const notificationMatch = url.pathname.match(
|
|
447
|
+
/^\/api\/bots\/([^/]+)\/notifications$/,
|
|
448
|
+
);
|
|
449
|
+
if (notificationMatch) {
|
|
450
|
+
let notificationBotId: string;
|
|
451
|
+
try {
|
|
452
|
+
notificationBotId = decodeURIComponent(notificationMatch[1]);
|
|
453
|
+
} catch {
|
|
454
|
+
return jsonError(400, "invalid bot id");
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
notificationBotId = decodeBotIdV1(notificationBotId);
|
|
458
|
+
} catch {
|
|
459
|
+
return jsonError(400, "invalid bot id");
|
|
460
|
+
}
|
|
461
|
+
const missingBot = await requireRegisteredBot(env, notificationBotId);
|
|
462
|
+
if (missingBot) return missingBot;
|
|
463
|
+
if (request.method === "GET") {
|
|
464
|
+
return Response.json({
|
|
465
|
+
schemaVersion: 1,
|
|
466
|
+
notifications: await env.BOT_STATE.listNotifications({
|
|
467
|
+
schemaVersion: 1,
|
|
468
|
+
botId: notificationBotId,
|
|
469
|
+
}),
|
|
470
|
+
} satisfies ClientNotificationListV1);
|
|
471
|
+
}
|
|
472
|
+
if (request.method !== "POST") {
|
|
473
|
+
return jsonError(405, "method not allowed");
|
|
474
|
+
}
|
|
475
|
+
let command;
|
|
476
|
+
try {
|
|
477
|
+
command = decodeClientNotificationAcknowledgementCommandV1(
|
|
478
|
+
await request.json(),
|
|
479
|
+
);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
return jsonError(
|
|
482
|
+
400,
|
|
483
|
+
error instanceof Error
|
|
484
|
+
? error.message
|
|
485
|
+
: "invalid notification acknowledgement",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
await env.BOT_STATE.acknowledgeNotification({
|
|
489
|
+
schemaVersion: 1,
|
|
490
|
+
botId: notificationBotId,
|
|
491
|
+
notificationId: command.notificationId,
|
|
492
|
+
});
|
|
493
|
+
return Response.json({
|
|
494
|
+
schemaVersion: 1,
|
|
495
|
+
status: "acknowledged",
|
|
496
|
+
} satisfies ClientNotificationAcknowledgementV1);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Approval cards (row 53). Bot-scoped and beside the notifications route,
|
|
500
|
+
// because a pending decision is Bot state the User answers, not a Package
|
|
501
|
+
// surface of its own.
|
|
502
|
+
const approvalsMatch = url.pathname.match(
|
|
503
|
+
/^\/api\/bots\/([^/]+)\/approvals$/,
|
|
504
|
+
);
|
|
505
|
+
const approvalMatch = url.pathname.match(
|
|
506
|
+
/^\/api\/bots\/([^/]+)\/approvals\/([^/]+)$/,
|
|
507
|
+
);
|
|
508
|
+
if (approvalsMatch || approvalMatch) {
|
|
509
|
+
let approvalBotId: string;
|
|
510
|
+
try {
|
|
511
|
+
approvalBotId = decodeBotIdV1(
|
|
512
|
+
decodeURIComponent((approvalsMatch ?? approvalMatch)![1]),
|
|
513
|
+
);
|
|
514
|
+
} catch {
|
|
515
|
+
return jsonError(400, "invalid bot id");
|
|
516
|
+
}
|
|
517
|
+
const missing = await requireRegisteredBot(env, approvalBotId);
|
|
518
|
+
if (missing) return missing;
|
|
519
|
+
if (approvalsMatch) {
|
|
520
|
+
if (request.method !== "GET") {
|
|
521
|
+
return jsonError(405, "method not allowed");
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
return Response.json(
|
|
525
|
+
await env.BOT_STATE.listApprovals({
|
|
526
|
+
schemaVersion: 1,
|
|
527
|
+
botId: approvalBotId,
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return botFailure(error, "approvals failed");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (request.method !== "POST") {
|
|
535
|
+
return jsonError(405, "method not allowed");
|
|
536
|
+
}
|
|
537
|
+
let approvalId: string;
|
|
538
|
+
let command;
|
|
539
|
+
try {
|
|
540
|
+
approvalId = decodeURIComponent(approvalMatch![2]);
|
|
541
|
+
if (!isRpcIdentifier(approvalId)) {
|
|
542
|
+
throw new Error("approval id is invalid");
|
|
543
|
+
}
|
|
544
|
+
command = decodeApprovalDecisionCommandV1(await request.json());
|
|
545
|
+
} catch (error) {
|
|
546
|
+
return jsonError(
|
|
547
|
+
400,
|
|
548
|
+
error instanceof Error ? error.message : "invalid approval decision",
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
// The decision is durable before this answers: "admit input durably
|
|
553
|
+
// before acknowledging" applies to a person's answer as much as to a
|
|
554
|
+
// Turn's, and a replay reads back the one decision that was recorded.
|
|
555
|
+
return Response.json(
|
|
556
|
+
await env.BOT_STATE.decideApproval({
|
|
557
|
+
schemaVersion: 1,
|
|
558
|
+
botId: approvalBotId,
|
|
559
|
+
approvalId,
|
|
560
|
+
command,
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
const message =
|
|
565
|
+
error instanceof Error ? error.message : "approval decision failed";
|
|
566
|
+
const name = error instanceof Error ? error.name : "";
|
|
567
|
+
if (name === "ApprovalNotFoundError") return jsonError(404, message);
|
|
568
|
+
return jsonError(500, message);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Cards (ADR 0030). Beside the approvals it can carry: a GET is the Bot's
|
|
573
|
+
// surfaces as they stand, a GET of one id is that surface whatever the
|
|
574
|
+
// listing's byte budget cut, and a POST is one action on one of them,
|
|
575
|
+
// which the kernel — never the Card — decides the meaning of.
|
|
576
|
+
const cardsMatch = url.pathname.match(/^\/api\/bots\/([^/]+)\/cards$/);
|
|
577
|
+
const cardMatch = url.pathname.match(
|
|
578
|
+
/^\/api\/bots\/([^/]+)\/cards\/([^/]+)$/,
|
|
579
|
+
);
|
|
580
|
+
if (cardsMatch || cardMatch) {
|
|
581
|
+
let cardBotId: string;
|
|
582
|
+
try {
|
|
583
|
+
cardBotId = decodeBotIdV1(
|
|
584
|
+
decodeURIComponent((cardsMatch ?? cardMatch)![1]!),
|
|
585
|
+
);
|
|
586
|
+
} catch {
|
|
587
|
+
return jsonError(400, "invalid bot id");
|
|
588
|
+
}
|
|
589
|
+
const missing = await requireRegisteredBot(env, cardBotId);
|
|
590
|
+
if (missing) return missing;
|
|
591
|
+
if (cardMatch) {
|
|
592
|
+
if (request.method !== "GET") {
|
|
593
|
+
return jsonError(405, "method not allowed");
|
|
594
|
+
}
|
|
595
|
+
let surfaceId: string;
|
|
596
|
+
try {
|
|
597
|
+
surfaceId = decodeCardSurfaceIdV1(decodeURIComponent(cardMatch[2]!));
|
|
598
|
+
} catch (error) {
|
|
599
|
+
return jsonError(
|
|
600
|
+
400,
|
|
601
|
+
error instanceof Error ? error.message : "invalid surface id",
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
return Response.json(
|
|
606
|
+
await env.BOT_STATE.readCard({
|
|
607
|
+
schemaVersion: 1,
|
|
608
|
+
botId: cardBotId,
|
|
609
|
+
surfaceId,
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
const message =
|
|
614
|
+
error instanceof Error ? error.message : "card read failed";
|
|
615
|
+
const name = error instanceof Error ? error.name : "";
|
|
616
|
+
if (name === "CardNotFoundError") return jsonError(404, message);
|
|
617
|
+
if (name === "CardDecodeError") return jsonError(400, message);
|
|
618
|
+
return jsonError(500, message);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (request.method === "GET") {
|
|
622
|
+
try {
|
|
623
|
+
return Response.json(
|
|
624
|
+
await env.BOT_STATE.listCards({
|
|
625
|
+
schemaVersion: 1,
|
|
626
|
+
botId: cardBotId,
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
return botFailure(error, "cards failed");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (request.method !== "POST") {
|
|
634
|
+
return jsonError(405, "method not allowed");
|
|
635
|
+
}
|
|
636
|
+
let command;
|
|
637
|
+
try {
|
|
638
|
+
command = decodeCardActionCommandV1(await request.json());
|
|
639
|
+
} catch (error) {
|
|
640
|
+
return jsonError(
|
|
641
|
+
400,
|
|
642
|
+
error instanceof Error ? error.message : "invalid card action",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
return Response.json(
|
|
647
|
+
await env.BOT_STATE.cardAction({
|
|
648
|
+
schemaVersion: 1,
|
|
649
|
+
botId: cardBotId,
|
|
650
|
+
command,
|
|
651
|
+
}),
|
|
652
|
+
);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
const message =
|
|
655
|
+
error instanceof Error ? error.message : "card action failed";
|
|
656
|
+
const name = error instanceof Error ? error.name : "";
|
|
657
|
+
// A surface that has moved is not a fault: the person answered the
|
|
658
|
+
// card they were shown, and the client redraws and asks again. A
|
|
659
|
+
// refused action is the client's, not the kernel's.
|
|
660
|
+
if (name === "CardStaleError") return jsonError(409, message);
|
|
661
|
+
if (name === "CardNotFoundError") return jsonError(404, message);
|
|
662
|
+
if (name === "CardDecodeError") return jsonError(400, message);
|
|
663
|
+
// An approval a Card named but the kernel does not hold is the same
|
|
664
|
+
// answer the approvals route gives for it, and for the same reason.
|
|
665
|
+
if (name === "ApprovalNotFoundError") return jsonError(404, message);
|
|
666
|
+
if (name === "ApprovalDecodeError") return jsonError(400, message);
|
|
667
|
+
return jsonError(500, message);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// A secret a person typed on a Bot's secret-request card. The body is
|
|
672
|
+
// decoded without ever being echoed, handed to the Bot that asked, and
|
|
673
|
+
// sealed by the User object before this answers; nothing here keeps,
|
|
674
|
+
// logs or repeats it, and a failure answers in fixed words.
|
|
675
|
+
const secretMatch = url.pathname.match(
|
|
676
|
+
/^\/api\/bots\/([^/]+)\/secret-requests\/([^/]+)$/,
|
|
677
|
+
);
|
|
678
|
+
if (secretMatch) {
|
|
679
|
+
if (request.method !== "POST") {
|
|
680
|
+
return jsonError(405, "method not allowed");
|
|
681
|
+
}
|
|
682
|
+
let secretBotId: string;
|
|
683
|
+
let requestId: string;
|
|
684
|
+
let command;
|
|
685
|
+
try {
|
|
686
|
+
secretBotId = decodeBotIdV1(decodeURIComponent(secretMatch[1]!));
|
|
687
|
+
requestId = decodeURIComponent(secretMatch[2]!);
|
|
688
|
+
if (!isSecretRequestIdV1(requestId)) {
|
|
689
|
+
return jsonError(404, "That secret request was not found.");
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
return jsonError(400, "invalid bot id");
|
|
693
|
+
}
|
|
694
|
+
const missing = await requireRegisteredBot(env, secretBotId);
|
|
695
|
+
if (missing) return missing;
|
|
696
|
+
try {
|
|
697
|
+
command = decodeSecretSubmitCommandV1(await request.json());
|
|
698
|
+
} catch (error) {
|
|
699
|
+
return jsonError(
|
|
700
|
+
400,
|
|
701
|
+
error instanceof SecretDecodeError
|
|
702
|
+
? error.message
|
|
703
|
+
: "That secret couldn't be read.",
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
return Response.json(
|
|
708
|
+
await env.BOT_STATE.submitSecret({
|
|
709
|
+
schemaVersion: 1,
|
|
710
|
+
botId: secretBotId,
|
|
711
|
+
requestId,
|
|
712
|
+
command,
|
|
713
|
+
}),
|
|
714
|
+
{ headers: { "cache-control": "no-store" } },
|
|
715
|
+
);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
const name = error instanceof Error ? error.name : "";
|
|
718
|
+
if (name === "SecretRequestNotFoundError") {
|
|
719
|
+
return jsonError(404, "That secret request was not found.");
|
|
720
|
+
}
|
|
721
|
+
if (name === "SecretLimitError" && error instanceof Error) {
|
|
722
|
+
return jsonError(409, error.message);
|
|
723
|
+
}
|
|
724
|
+
return jsonError(500, "The secret couldn't be saved. Try again.");
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const skillsMatch = url.pathname.match(/^\/api\/bots\/([^/]+)\/skills$/);
|
|
729
|
+
const workspaceFileMatch = url.pathname.match(
|
|
730
|
+
/^\/api\/bots\/([^/]+)\/workspace\/file$/,
|
|
731
|
+
);
|
|
732
|
+
const turnMatch = url.pathname.match(/^\/api\/bots\/([^/]+)\/turns$/);
|
|
733
|
+
const lookupMatch = url.pathname.match(
|
|
734
|
+
/^\/api\/bots\/([^/]+)\/turns\/([^/]+)$/,
|
|
735
|
+
);
|
|
736
|
+
const fenceMatch = url.pathname.match(
|
|
737
|
+
/^\/api\/bots\/([^/]+)\/turns\/([^/]+)\/fence$/,
|
|
738
|
+
);
|
|
739
|
+
const questionsMatch = url.pathname.match(
|
|
740
|
+
/^\/api\/bots\/([^/]+)\/turns\/([^/]+)\/questions$/,
|
|
741
|
+
);
|
|
742
|
+
const stopMatch = url.pathname.match(
|
|
743
|
+
/^\/api\/bots\/([^/]+)\/turns\/([^/]+)\/stop$/,
|
|
744
|
+
);
|
|
745
|
+
if (
|
|
746
|
+
!skillsMatch &&
|
|
747
|
+
!workspaceFileMatch &&
|
|
748
|
+
!turnMatch &&
|
|
749
|
+
!lookupMatch &&
|
|
750
|
+
!questionsMatch &&
|
|
751
|
+
!fenceMatch &&
|
|
752
|
+
!stopMatch
|
|
753
|
+
) {
|
|
754
|
+
return jsonError(404, "not found");
|
|
755
|
+
}
|
|
756
|
+
let botId: string;
|
|
757
|
+
try {
|
|
758
|
+
const matched =
|
|
759
|
+
skillsMatch ??
|
|
760
|
+
workspaceFileMatch ??
|
|
761
|
+
turnMatch ??
|
|
762
|
+
lookupMatch ??
|
|
763
|
+
questionsMatch ??
|
|
764
|
+
fenceMatch ??
|
|
765
|
+
stopMatch;
|
|
766
|
+
botId = decodeURIComponent(matched![1]);
|
|
767
|
+
} catch {
|
|
768
|
+
return jsonError(400, "invalid bot id");
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
botId = decodeBotIdV1(botId);
|
|
772
|
+
} catch {
|
|
773
|
+
return jsonError(400, "invalid bot id");
|
|
774
|
+
}
|
|
775
|
+
const missingBot = await requireRegisteredBot(
|
|
776
|
+
env,
|
|
777
|
+
botId,
|
|
778
|
+
request.method === "GET" &&
|
|
779
|
+
Boolean(turnMatch || lookupMatch || questionsMatch),
|
|
780
|
+
);
|
|
781
|
+
if (missingBot) return missingBot;
|
|
782
|
+
// An installed client older than protocol 3 refuses a Run it does not
|
|
783
|
+
// know every field of, so the files are left off what it is sent.
|
|
784
|
+
const forClient = <T>(value: T): T =>
|
|
785
|
+
clientProtocolOfV1(request.headers.get("x-frockbot-client")) >=
|
|
786
|
+
RUN_ATTACHMENTS_PROTOCOL_V1
|
|
787
|
+
? value
|
|
788
|
+
: withoutRunAttachmentsV1(value);
|
|
789
|
+
|
|
790
|
+
if (skillsMatch) {
|
|
791
|
+
// Read-only, and named refs only: the popover learns which Skills exist
|
|
792
|
+
// and never receives a body.
|
|
793
|
+
if (request.method !== "GET") return jsonError(405, "method not allowed");
|
|
794
|
+
try {
|
|
795
|
+
return Response.json(
|
|
796
|
+
await env.BOT_STATE.listSkills({ schemaVersion: 1, botId }),
|
|
797
|
+
);
|
|
798
|
+
} catch (error) {
|
|
799
|
+
return botFailure(error, "skill catalog failed");
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (workspaceFileMatch) {
|
|
804
|
+
// Read-only, and the durable root and path arrive as one encoded
|
|
805
|
+
// `WorkspacePathV1` so the route cannot assemble a root the decoder
|
|
806
|
+
// would not accept. The bytes come from object storage; no Computer
|
|
807
|
+
// wakes to serve this.
|
|
808
|
+
if (request.method !== "GET") return jsonError(405, "method not allowed");
|
|
809
|
+
const encoded = url.searchParams.get("path");
|
|
810
|
+
if (!encoded) return jsonError(400, "a workspace path is required");
|
|
811
|
+
let path: unknown;
|
|
812
|
+
try {
|
|
813
|
+
path = JSON.parse(encoded);
|
|
814
|
+
} catch {
|
|
815
|
+
return jsonError(400, "invalid workspace path");
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
const answer = await env.BOT_STATE.readWorkspaceFileV1({
|
|
819
|
+
schemaVersion: 1,
|
|
820
|
+
botId,
|
|
821
|
+
path,
|
|
822
|
+
});
|
|
823
|
+
if (answer.status !== "ok") {
|
|
824
|
+
return jsonError(
|
|
825
|
+
answer.status === "not-found" ? 404 : 409,
|
|
826
|
+
"reason" in answer ? answer.reason : "workspace read failed",
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
const binary = atob(answer.bytesBase64);
|
|
830
|
+
const bytes = new Uint8Array(binary.length);
|
|
831
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
832
|
+
bytes[index] = binary.charCodeAt(index);
|
|
833
|
+
}
|
|
834
|
+
return new Response(bytes, {
|
|
835
|
+
headers: {
|
|
836
|
+
"content-type": "application/octet-stream",
|
|
837
|
+
"cache-control": "private, max-age=60",
|
|
838
|
+
etag: `"${answer.contentHash}"`,
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
} catch (error) {
|
|
842
|
+
return jsonError(
|
|
843
|
+
400,
|
|
844
|
+
error instanceof Error ? error.message : "workspace read failed",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (fenceMatch) {
|
|
850
|
+
if (request.method !== "POST") {
|
|
851
|
+
return jsonError(405, "method not allowed");
|
|
852
|
+
}
|
|
853
|
+
let query: ClientRunLookupQueryV1;
|
|
854
|
+
try {
|
|
855
|
+
decodeClientRunAdmissionFenceCommandV1(await request.json());
|
|
856
|
+
query = decodeClientRunLookupQueryV1({
|
|
857
|
+
schemaVersion: 1,
|
|
858
|
+
runId: decodeURIComponent(fenceMatch[2]),
|
|
859
|
+
});
|
|
860
|
+
} catch (error) {
|
|
861
|
+
return jsonError(
|
|
862
|
+
400,
|
|
863
|
+
error instanceof Error ? error.message : "invalid admission fence",
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
try {
|
|
867
|
+
return Response.json(
|
|
868
|
+
forClient(
|
|
869
|
+
await env.BOT_STATE.fenceRunAdmission({
|
|
870
|
+
schemaVersion: 1,
|
|
871
|
+
botId,
|
|
872
|
+
query,
|
|
873
|
+
}),
|
|
874
|
+
),
|
|
875
|
+
);
|
|
876
|
+
} catch (error) {
|
|
877
|
+
return botFailure(error, "admission fence failed");
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (stopMatch) {
|
|
882
|
+
if (request.method !== "POST") {
|
|
883
|
+
return jsonError(405, "method not allowed");
|
|
884
|
+
}
|
|
885
|
+
let command: ClientRunStopCommandV1;
|
|
886
|
+
try {
|
|
887
|
+
const body: unknown = await request.json();
|
|
888
|
+
command = decodeClientRunStopCommandV1(body);
|
|
889
|
+
if (command.runId !== decodeURIComponent(stopMatch[2])) {
|
|
890
|
+
throw new Error("run stop command does not match the request path");
|
|
891
|
+
}
|
|
892
|
+
} catch (error) {
|
|
893
|
+
return jsonError(
|
|
894
|
+
400,
|
|
895
|
+
error instanceof Error ? error.message : "invalid stop command",
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
return Response.json(
|
|
900
|
+
forClient(
|
|
901
|
+
await env.BOT_STATE.stopRun({ schemaVersion: 1, botId, command }),
|
|
902
|
+
),
|
|
903
|
+
);
|
|
904
|
+
} catch (error) {
|
|
905
|
+
return jsonError(
|
|
906
|
+
409,
|
|
907
|
+
error instanceof Error ? error.message : "Stop failed",
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (questionsMatch) {
|
|
913
|
+
if (request.method !== "GET") {
|
|
914
|
+
return jsonError(405, "method not allowed");
|
|
915
|
+
}
|
|
916
|
+
let query: ClientRunLookupQueryV1;
|
|
917
|
+
try {
|
|
918
|
+
if ([...url.searchParams.keys()].length > 0) {
|
|
919
|
+
throw new Error("run questions do not accept URL parameters");
|
|
920
|
+
}
|
|
921
|
+
query = decodeClientRunLookupQueryV1({
|
|
922
|
+
schemaVersion: 1,
|
|
923
|
+
runId: decodeURIComponent(questionsMatch[2]),
|
|
924
|
+
});
|
|
925
|
+
} catch (error) {
|
|
926
|
+
return jsonError(
|
|
927
|
+
400,
|
|
928
|
+
error instanceof Error ? error.message : "invalid run questions",
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
try {
|
|
932
|
+
return Response.json(
|
|
933
|
+
await env.BOT_STATE.runQuestions({ schemaVersion: 1, botId, query }),
|
|
934
|
+
);
|
|
935
|
+
} catch (error) {
|
|
936
|
+
return botFailure(error, "run questions failed");
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (lookupMatch) {
|
|
941
|
+
if (request.method !== "GET") {
|
|
942
|
+
return jsonError(405, "method not allowed");
|
|
943
|
+
}
|
|
944
|
+
let query: ClientRunLookupQueryV1;
|
|
945
|
+
try {
|
|
946
|
+
if ([...url.searchParams.keys()].length > 0) {
|
|
947
|
+
throw new Error("run lookup query does not accept URL parameters");
|
|
948
|
+
}
|
|
949
|
+
query = decodeClientRunLookupQueryV1({
|
|
950
|
+
schemaVersion: 1,
|
|
951
|
+
runId: decodeURIComponent(lookupMatch[2]),
|
|
952
|
+
});
|
|
953
|
+
} catch (error) {
|
|
954
|
+
return jsonError(
|
|
955
|
+
400,
|
|
956
|
+
error instanceof Error ? error.message : "invalid run lookup",
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
return Response.json(
|
|
961
|
+
forClient(
|
|
962
|
+
await env.BOT_STATE.lookupRun({ schemaVersion: 1, botId, query }),
|
|
963
|
+
),
|
|
964
|
+
);
|
|
965
|
+
} catch (error) {
|
|
966
|
+
return botFailure(error, "run lookup failed");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (request.method === "GET") {
|
|
971
|
+
let query;
|
|
972
|
+
try {
|
|
973
|
+
const queryKeys = [...url.searchParams.keys()];
|
|
974
|
+
if (
|
|
975
|
+
queryKeys.some((key) => key !== "before" && key !== "with") ||
|
|
976
|
+
url.searchParams.getAll("before").length > 1 ||
|
|
977
|
+
url.searchParams.getAll("with").length > 1
|
|
978
|
+
) {
|
|
979
|
+
throw new Error("run list query is invalid");
|
|
980
|
+
}
|
|
981
|
+
const before = url.searchParams.get("before");
|
|
982
|
+
const counterpart = url.searchParams.get("with");
|
|
983
|
+
query = decodeClientRunListQueryV1({
|
|
984
|
+
schemaVersion: 1,
|
|
985
|
+
...(before === null ? {} : { before }),
|
|
986
|
+
...(counterpart === null
|
|
987
|
+
? {}
|
|
988
|
+
: { counterpart: parseExchangeCounterpartParamV1(counterpart) }),
|
|
989
|
+
});
|
|
990
|
+
} catch (error) {
|
|
991
|
+
return jsonError(
|
|
992
|
+
400,
|
|
993
|
+
error instanceof Error ? error.message : "invalid run page",
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
return Response.json(
|
|
998
|
+
forClient(
|
|
999
|
+
await env.BOT_STATE.listRuns({ schemaVersion: 1, botId, query }),
|
|
1000
|
+
),
|
|
1001
|
+
);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
// A stored run the current codec refuses is a visible failure with
|
|
1004
|
+
// its reason, never a crash of the whole application Worker.
|
|
1005
|
+
return botFailure(error, "run list failed");
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (request.method !== "POST") return jsonError(405, "method not allowed");
|
|
1009
|
+
|
|
1010
|
+
let turnCommand: ClientTurnCommandV1;
|
|
1011
|
+
try {
|
|
1012
|
+
turnCommand = await readTurnCommand(request);
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
if (isRequestTooLargeV1(error)) {
|
|
1015
|
+
return jsonError(413, TURN_TOO_LONG_MESSAGE_V1);
|
|
1016
|
+
}
|
|
1017
|
+
return jsonError(
|
|
1018
|
+
400,
|
|
1019
|
+
error instanceof Error ? error.message : "invalid prompt",
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
return Response.json(
|
|
1025
|
+
// Every Turn a client asks for is admitted as `chat`. The turn type is
|
|
1026
|
+
// never carried here: `decodeClientTurnCommandV1` accepts exact keys,
|
|
1027
|
+
// and the Bot Durable Object's run RPC accepts exact keys too, so a
|
|
1028
|
+
// client cannot name one, and an absent turn type means `chat`. Only an
|
|
1029
|
+
// in-Durable-Object producer may admit another type.
|
|
1030
|
+
//
|
|
1031
|
+
// The answer is the admission receipt. Execution continues on the Bot
|
|
1032
|
+
// object's drive and recovery alarm; this response does not wait for
|
|
1033
|
+
// either.
|
|
1034
|
+
await env.BOT_STATE.admitRun({
|
|
1035
|
+
schemaVersion: 1,
|
|
1036
|
+
botId,
|
|
1037
|
+
command: {
|
|
1038
|
+
runId: turnCommand.commandId,
|
|
1039
|
+
sessionId: `${env.DEPLOYMENT.userId}:${botId}`,
|
|
1040
|
+
acceptedAt: new Date().toISOString(),
|
|
1041
|
+
text: turnCommand.text,
|
|
1042
|
+
...(turnCommand.retryOf ? { retryOf: turnCommand.retryOf } : {}),
|
|
1043
|
+
// Refs only: the client names a Skill and never carries its text,
|
|
1044
|
+
// so what a Turn runs on is still whatever the instruction root
|
|
1045
|
+
// holds at the generation the Turn resolves.
|
|
1046
|
+
...(turnCommand.skills ? { skills: turnCommand.skills } : {}),
|
|
1047
|
+
// Refs again: the bytes were admitted by the upload route, and
|
|
1048
|
+
// the Bot resolves each name against its own uploads.
|
|
1049
|
+
...(turnCommand.attachments
|
|
1050
|
+
? { attachments: turnCommand.attachments }
|
|
1051
|
+
: {}),
|
|
1052
|
+
},
|
|
1053
|
+
}),
|
|
1054
|
+
{ status: 202 },
|
|
1055
|
+
);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
const refusal = turnRefusal(error);
|
|
1058
|
+
if (refusal) return Response.json(refusal, { status: 409 });
|
|
1059
|
+
return botFailure(error, "Bot turn failed");
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const fetchUserApplication = createUserApplication();
|
|
1065
|
+
|
|
1066
|
+
export default {
|
|
1067
|
+
fetch: fetchUserApplication,
|
|
1068
|
+
} satisfies ExportedHandler<UserApplicationEnv>;
|