@ixo/oracle-runtime-workers 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/action-interactions/routes.ts +78 -0
- package/src/action-interactions/store.ts +1038 -0
- package/src/core/middlewares/tool-repetition-guard.ts +61 -1
- package/src/do/contracts.ts +32 -0
- package/src/do/user-oracle-do.ts +226 -3
- package/src/index.ts +2 -0
- package/src/plugins/asks/asks.plugin.ts +226 -0
- package/src/plugins/asks/attach.ts +342 -0
- package/src/plugins/asks/connection-do.ts +69 -0
- package/src/plugins/asks/connection.ts +368 -0
- package/src/plugins/asks/guide.ts +18 -0
- package/src/plugins/asks/index.ts +5 -0
- package/src/plugins/asks/oauth.ts +381 -0
- package/src/plugins/asks/routes.ts +258 -0
- package/src/plugins/asks/skill-pick.ts +114 -0
- package/src/plugins/asks/skills.bundled.json +27 -0
- package/src/plugins/asks/skills.ts +207 -0
- package/src/plugins/asks/testing.ts +147 -0
- package/src/plugins/asks/tools.ts +714 -0
- package/src/plugins/index.ts +8 -0
- package/src/plugins/portal/portal.plugin.ts +2 -1
- package/src/plugins/tasks/tasks-tools.ts +20 -20
- package/src/shell/app.ts +241 -6
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file the person sent in the chat, streamed from the channel gateway's
|
|
3
|
+
* media link to the upload address submit_work handed out for an answer
|
|
4
|
+
* draft (asks `workers/src/draftUploads.ts`, #656: a signed R2 PUT per file).
|
|
5
|
+
* The bytes never pass through the model, and neither the media link, the
|
|
6
|
+
* upload address nor the signed URL is ever shown to it.
|
|
7
|
+
*/
|
|
8
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
9
|
+
import { bytesToHex } from '@noble/hashes/utils';
|
|
10
|
+
|
|
11
|
+
/** What asks' draft uploads take: PNG, JPG, WebP or PDF, 5 MB each (10 files, 25 MB a draft). */
|
|
12
|
+
export const UPLOAD_TYPES: Readonly<Record<string, string>> = {
|
|
13
|
+
'image/png': 'png',
|
|
14
|
+
'image/jpeg': 'jpg',
|
|
15
|
+
'image/webp': 'webp',
|
|
16
|
+
'application/pdf': 'pdf',
|
|
17
|
+
};
|
|
18
|
+
export const MAX_UPLOAD_BYTES = 5_000_000;
|
|
19
|
+
|
|
20
|
+
/** A channel file as the plugin keeps it (the turn's `session.channel.attachments`). */
|
|
21
|
+
export interface KeptFile {
|
|
22
|
+
url: string;
|
|
23
|
+
sha256: string;
|
|
24
|
+
mimetype: string;
|
|
25
|
+
size: number;
|
|
26
|
+
filename: string;
|
|
27
|
+
bindingId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The https origins of the gateways named in `CHANNEL_SERVICE_DID` (`did:web:<host>`, comma separated). */
|
|
31
|
+
export function channelOriginsOf(value: unknown): string[] {
|
|
32
|
+
if (typeof value !== 'string') return [];
|
|
33
|
+
return value.split(/[\s,]+/).flatMap((did) => {
|
|
34
|
+
const host = /^did:web:([^:]+)/.exec(did)?.[1];
|
|
35
|
+
if (!host) return [];
|
|
36
|
+
try {
|
|
37
|
+
return [new URL(`https://${decodeURIComponent(host)}`).origin];
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A file id the way asks names one: letters, digits, `_` and `-`, starting with a letter or digit. */
|
|
45
|
+
export function fileIdOf(name: string): string {
|
|
46
|
+
const id = name
|
|
47
|
+
.replace(/\.[^.]*$/, '')
|
|
48
|
+
.replace(/[^A-Za-z0-9_-]+/g, '-')
|
|
49
|
+
.replace(/^[-_]+|-+$/g, '')
|
|
50
|
+
.slice(0, 64);
|
|
51
|
+
return id || 'file';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** asks' refusal words, with any link taken out. */
|
|
55
|
+
const scrub = (words: string): string =>
|
|
56
|
+
words.replace(/https?:\/\/\S+/g, 'the upload address').slice(0, 300);
|
|
57
|
+
|
|
58
|
+
/* ── asks' upload address (asks #656): sign, PUT to R2, check ───────────────
|
|
59
|
+
* The only code that talks to it. `<address>` is
|
|
60
|
+
* `https://{test.}api.asks.world/draft-uploads/du_…/`:
|
|
61
|
+
* POST <address><name> {type, bytes} → 201 {url, method: "PUT", headers}
|
|
62
|
+
* PUT <url> the bytes, with exactly the signed headers (R2, 10 minutes)
|
|
63
|
+
* GET <address> the server's check → {files, refused, missing}
|
|
64
|
+
* DELETE <address><name>
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/** A signed PUT asks handed out: always to R2, never anywhere else. */
|
|
68
|
+
export interface SignedPut {
|
|
69
|
+
url: string;
|
|
70
|
+
headers: Record<string, string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Refusal = { ok: false; unreachable: boolean; words: string };
|
|
74
|
+
|
|
75
|
+
const isR2 = (url: string): boolean => {
|
|
76
|
+
try {
|
|
77
|
+
const one = new URL(url);
|
|
78
|
+
return (
|
|
79
|
+
one.protocol === 'https:' &&
|
|
80
|
+
!one.username &&
|
|
81
|
+
one.hostname.endsWith('.r2.cloudflarestorage.com')
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
async function refusalOf(response: Response): Promise<Refusal> {
|
|
89
|
+
const answer = (await response.json().catch(() => ({}))) as {
|
|
90
|
+
words?: unknown;
|
|
91
|
+
code?: unknown;
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
unreachable: response.status >= 500,
|
|
96
|
+
words:
|
|
97
|
+
typeof answer.words === 'string'
|
|
98
|
+
? answer.words
|
|
99
|
+
: `asks.world refused it (${String(answer.code ?? response.status)}).`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Ask asks for a signed PUT for one file of `bytes` bytes. */
|
|
104
|
+
export async function signDraftUpload(
|
|
105
|
+
fetcher: typeof fetch,
|
|
106
|
+
address: string,
|
|
107
|
+
name: string,
|
|
108
|
+
type: string,
|
|
109
|
+
bytes: number,
|
|
110
|
+
): Promise<{ ok: true; put: SignedPut } | Refusal> {
|
|
111
|
+
const response = await fetcher(`${address}${encodeURIComponent(name)}`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({ type, bytes }),
|
|
115
|
+
}).catch(() => null);
|
|
116
|
+
if (!response)
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
unreachable: true,
|
|
120
|
+
words: 'asks.world could not be reached.',
|
|
121
|
+
};
|
|
122
|
+
if (!response.ok) return refusalOf(response);
|
|
123
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
124
|
+
url?: unknown;
|
|
125
|
+
method?: unknown;
|
|
126
|
+
headers?: unknown;
|
|
127
|
+
};
|
|
128
|
+
const headers = body.headers as Record<string, unknown> | undefined;
|
|
129
|
+
if (
|
|
130
|
+
typeof body.url !== 'string' ||
|
|
131
|
+
!isR2(body.url) ||
|
|
132
|
+
(body.method ?? 'PUT') !== 'PUT' ||
|
|
133
|
+
!headers ||
|
|
134
|
+
typeof headers !== 'object' ||
|
|
135
|
+
!Object.values(headers).every((one) => typeof one === 'string')
|
|
136
|
+
)
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
unreachable: false,
|
|
140
|
+
words: 'asks.world gave an upload link this chat does not use.',
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
put: { url: body.url, headers: headers as Record<string, string> },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Stream the bytes to the signed URL, with exactly its headers. */
|
|
149
|
+
export async function putSigned(
|
|
150
|
+
fetcher: typeof fetch,
|
|
151
|
+
put: SignedPut,
|
|
152
|
+
body: ReadableStream<Uint8Array>,
|
|
153
|
+
bytes: number,
|
|
154
|
+
): Promise<{ ok: true } | Refusal> {
|
|
155
|
+
// Workers send a stream with a Content-Length only through a FixedLengthStream.
|
|
156
|
+
const Fixed = (
|
|
157
|
+
globalThis as {
|
|
158
|
+
FixedLengthStream?: new (
|
|
159
|
+
length: number,
|
|
160
|
+
) => TransformStream<Uint8Array, Uint8Array>;
|
|
161
|
+
}
|
|
162
|
+
).FixedLengthStream;
|
|
163
|
+
const response = await fetcher(put.url, {
|
|
164
|
+
method: 'PUT',
|
|
165
|
+
headers: put.headers,
|
|
166
|
+
body: Fixed ? body.pipeThrough(new Fixed(bytes)) : body,
|
|
167
|
+
}).catch(() => null);
|
|
168
|
+
if (!response)
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
unreachable: true,
|
|
172
|
+
words: 'The upload did not finish.',
|
|
173
|
+
};
|
|
174
|
+
// R2's own error is XML, nothing to show.
|
|
175
|
+
return response.ok
|
|
176
|
+
? { ok: true }
|
|
177
|
+
: {
|
|
178
|
+
ok: false,
|
|
179
|
+
unreachable: false,
|
|
180
|
+
words: `The upload was refused (${response.status}).`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function removeDraftUpload(
|
|
185
|
+
fetcher: typeof fetch,
|
|
186
|
+
address: string,
|
|
187
|
+
name: string,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
await fetcher(`${address}${encodeURIComponent(name)}`, {
|
|
190
|
+
method: 'DELETE',
|
|
191
|
+
}).catch(() => undefined);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** asks' own check of what landed: the names it refused and the file ids still missing. */
|
|
195
|
+
export async function checkDraftUploads(
|
|
196
|
+
fetcher: typeof fetch,
|
|
197
|
+
address: string,
|
|
198
|
+
): Promise<
|
|
199
|
+
| { ok: true; kept: string[]; refused: string[]; missing: string[] }
|
|
200
|
+
| { ok: false }
|
|
201
|
+
> {
|
|
202
|
+
const response = await fetcher(address, {
|
|
203
|
+
headers: { accept: 'application/json' },
|
|
204
|
+
}).catch(() => null);
|
|
205
|
+
if (!response?.ok) return { ok: false };
|
|
206
|
+
const body = (await response.json().catch(() => null)) as {
|
|
207
|
+
files?: unknown;
|
|
208
|
+
refused?: unknown;
|
|
209
|
+
missing?: unknown;
|
|
210
|
+
} | null;
|
|
211
|
+
if (!body) return { ok: false };
|
|
212
|
+
// Names and ids only, and only ones shaped like a file name: nothing else reaches the model.
|
|
213
|
+
const names = (list: unknown, pick: (one: unknown) => unknown): string[] =>
|
|
214
|
+
(Array.isArray(list) ? list : [])
|
|
215
|
+
.map(pick)
|
|
216
|
+
.filter(
|
|
217
|
+
(one): one is string =>
|
|
218
|
+
typeof one === 'string' &&
|
|
219
|
+
/^[A-Za-z0-9][A-Za-z0-9_.-]{0,80}$/.test(one),
|
|
220
|
+
)
|
|
221
|
+
.slice(0, 12);
|
|
222
|
+
const named = (one: unknown) => (one as { name?: unknown } | null)?.name;
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
kept: names(body.files, named),
|
|
226
|
+
refused: names(body.refused, named),
|
|
227
|
+
missing: names(body.missing, (one) => one),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function sendFile(input: {
|
|
232
|
+
file: KeptFile;
|
|
233
|
+
/** The file id the answer expects; else one made from the file's name. */
|
|
234
|
+
name?: string;
|
|
235
|
+
address: string;
|
|
236
|
+
origins: readonly string[];
|
|
237
|
+
fetch: typeof fetch;
|
|
238
|
+
}): Promise<{ ok: true; as: string } | { ok: false; why: string }> {
|
|
239
|
+
const { file, address } = input;
|
|
240
|
+
const ext = UPLOAD_TYPES[file.mimetype.toLowerCase()];
|
|
241
|
+
if (!ext)
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
why: `Asks takes PNG, JPG, WebP or PDF files; this one is ${file.mimetype}.`,
|
|
245
|
+
};
|
|
246
|
+
if (file.size > MAX_UPLOAD_BYTES)
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
why: 'Asks takes files up to 5 MB; this one is bigger.',
|
|
250
|
+
};
|
|
251
|
+
let source: URL;
|
|
252
|
+
try {
|
|
253
|
+
source = new URL(file.url);
|
|
254
|
+
} catch {
|
|
255
|
+
return { ok: false, why: "The chat's copy of that file can't be read." };
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
!input.origins.includes(source.origin) ||
|
|
259
|
+
!source.pathname.startsWith(`/media/${file.bindingId}/`)
|
|
260
|
+
) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
why: "That file isn't one the chat gateway keeps, so it was not sent.",
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const fetched = await input
|
|
268
|
+
.fetch(source.toString(), { redirect: 'manual' })
|
|
269
|
+
.catch(() => null);
|
|
270
|
+
if (!fetched?.ok || !fetched.body) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
why: "The chat's copy of that file can't be read any more (links last 30 days).",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
// asks signs for an exact size: the gateway's own length must agree with it.
|
|
277
|
+
const length = fetched.headers.get('content-length');
|
|
278
|
+
if (length !== null && Number(length) !== file.size) {
|
|
279
|
+
await fetched.body.cancel().catch(() => undefined);
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
why: 'The file changed on its way from the chat, so it was not attached.',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const hash = sha256.create();
|
|
286
|
+
let bytes = 0;
|
|
287
|
+
const meter = new TransformStream<Uint8Array, Uint8Array>({
|
|
288
|
+
transform(chunk, controller) {
|
|
289
|
+
bytes += chunk.byteLength;
|
|
290
|
+
if (bytes > file.size) {
|
|
291
|
+
controller.error(new Error('larger than the gateway said'));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
hash.update(chunk);
|
|
295
|
+
controller.enqueue(chunk);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const as = `${input.name ?? fileIdOf(file.filename)}.${ext}`;
|
|
300
|
+
const signed = await signDraftUpload(
|
|
301
|
+
input.fetch,
|
|
302
|
+
address,
|
|
303
|
+
as,
|
|
304
|
+
file.mimetype.toLowerCase(),
|
|
305
|
+
file.size,
|
|
306
|
+
);
|
|
307
|
+
if (!signed.ok) {
|
|
308
|
+
await fetched.body.cancel().catch(() => undefined);
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
why: signed.unreachable
|
|
312
|
+
? 'asks.world could not be reached, so the file was not attached. Try again in a minute.'
|
|
313
|
+
: scrub(signed.words),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const put = await putSigned(
|
|
317
|
+
input.fetch,
|
|
318
|
+
signed.put,
|
|
319
|
+
fetched.body.pipeThrough(meter),
|
|
320
|
+
file.size,
|
|
321
|
+
);
|
|
322
|
+
if (!put.ok && put.unreachable) {
|
|
323
|
+
return bytes > file.size
|
|
324
|
+
? {
|
|
325
|
+
ok: false,
|
|
326
|
+
why: 'The file changed on its way from the chat, so it was not attached.',
|
|
327
|
+
}
|
|
328
|
+
: {
|
|
329
|
+
ok: false,
|
|
330
|
+
why: 'asks.world could not be reached, so the file was not attached. Try again in a minute.',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (!put.ok) return { ok: false, why: scrub(put.words) };
|
|
334
|
+
if (bytes !== file.size || bytesToHex(hash.digest()) !== file.sha256) {
|
|
335
|
+
await removeDraftUpload(input.fetch, address, as);
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
why: 'The file changed on its way from the chat, so it was taken off again.',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return { ok: true, as };
|
|
342
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { AsksConnection, type AsksWorkerEnv, type Surface } from './connection';
|
|
3
|
+
import { asksMcpUrlOf } from './oauth';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One per person, named HMAC(ASKS_TOKEN_KEY, user DID). Reached from the
|
|
7
|
+
* user's own object (the tools) and from the shell (the sign-in routes), so
|
|
8
|
+
* the tokens live here rather than in either of them. RPC methods only.
|
|
9
|
+
*/
|
|
10
|
+
export class AsksConnectionDO extends DurableObject<AsksWorkerEnv> {
|
|
11
|
+
private readonly connection: AsksConnection;
|
|
12
|
+
|
|
13
|
+
constructor(ctx: DurableObjectState, env: AsksWorkerEnv) {
|
|
14
|
+
super(ctx, env);
|
|
15
|
+
const { ORACLE_PUBLIC_URL, ASKS_TOKEN_KEY } = env;
|
|
16
|
+
const mcpUrl = asksMcpUrlOf(env.NETWORK);
|
|
17
|
+
if (!mcpUrl || !ORACLE_PUBLIC_URL || !ASKS_TOKEN_KEY) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'asks: NETWORK (mainnet, testnet or devnet), ORACLE_PUBLIC_URL and ASKS_TOKEN_KEY are required',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
this.connection = new AsksConnection({
|
|
23
|
+
storage: ctx.storage,
|
|
24
|
+
config: {
|
|
25
|
+
mcpUrl,
|
|
26
|
+
publicUrl: ORACLE_PUBLIC_URL,
|
|
27
|
+
tokenKey: ASKS_TOKEN_KEY,
|
|
28
|
+
},
|
|
29
|
+
fetch: (input, init) => fetch(input, init),
|
|
30
|
+
now: () => Date.now(),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
begin(did: string, surface: Surface) {
|
|
35
|
+
return this.connection.begin(did, surface);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
authorizeUrl(state: string) {
|
|
39
|
+
return this.connection.authorizeUrl(state);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
complete(state: string, code: string) {
|
|
43
|
+
return this.connection.complete(state, code);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
status() {
|
|
47
|
+
return this.connection.status();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
token() {
|
|
51
|
+
return this.connection.token();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
refused(access: string) {
|
|
55
|
+
return this.connection.refused(access);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
disconnect() {
|
|
59
|
+
return this.connection.disconnect();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
keepUpload(draft: string, address: string, expiresAt: number) {
|
|
63
|
+
return this.connection.keepUpload(draft, address, expiresAt);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
uploadOf(draft: string) {
|
|
67
|
+
return this.connection.uploadOf(draft);
|
|
68
|
+
}
|
|
69
|
+
}
|