@lazyingart/agent-web 0.1.40
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/LICENSE +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AGINTI_MAX_FILE_ARTIFACT_BYTES,
|
|
5
|
+
AGINTI_RPC_PATHS,
|
|
6
|
+
FAIL_CLOSED_AGENT_CAPABILITIES,
|
|
7
|
+
canonicalJson,
|
|
8
|
+
rpcPathIsMutation,
|
|
9
|
+
validateArtifactId,
|
|
10
|
+
validateFileSpec,
|
|
11
|
+
validateAgentRequest,
|
|
12
|
+
validateAgentResponse,
|
|
13
|
+
validateEventEnvelope,
|
|
14
|
+
} from "./web/aginti-protocol.js";
|
|
15
|
+
|
|
16
|
+
const JSON_LIMIT = 2 * 1024 * 1024;
|
|
17
|
+
const STREAM_LIMIT = 8 * 1024 * 1024;
|
|
18
|
+
const SSE_BLOCK_LIMIT = 64 * 1024;
|
|
19
|
+
const PRINCIPAL_ID = /^[A-Za-z0-9._~-]{16,128}$/u;
|
|
20
|
+
const BROWSER_SESSION = /^[a-f0-9]{64}$/u;
|
|
21
|
+
const TOKEN = /^[A-Za-z0-9._~+/=-]{32,4096}$/u;
|
|
22
|
+
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
23
|
+
const DECIMAL = /^(?:0|[1-9]\d*)$/u;
|
|
24
|
+
|
|
25
|
+
export const AGINTI_ARTIFACT_CONTENT_PATH = "/agent/v1/artifacts/content";
|
|
26
|
+
|
|
27
|
+
export const AGINTI_INTERNAL_HEADERS = Object.freeze({
|
|
28
|
+
principal: "x-aginti-principal-id",
|
|
29
|
+
browserSession: "x-aginti-browser-session-id",
|
|
30
|
+
idempotency: "idempotency-key",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export class AgintiAdapterError extends Error {
|
|
34
|
+
constructor(message, { code = "AGINTI_UNAVAILABLE", statusCode = 503, retryable = true } = {}) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "AgintiAdapterError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.statusCode = statusCode;
|
|
39
|
+
this.retryable = retryable;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fail(message, options) {
|
|
44
|
+
throw new AgintiAdapterError(message, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function exactDataObject(value, allowed, label, required = allowed) {
|
|
48
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
49
|
+
throw new TypeError(`${label} must be a plain object`);
|
|
50
|
+
}
|
|
51
|
+
const prototype = Object.getPrototypeOf(value);
|
|
52
|
+
if (prototype !== Object.prototype && prototype !== null) throw new TypeError(`${label} must be a plain object`);
|
|
53
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
54
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
55
|
+
if (typeof key !== "string" || !allowed.includes(key)) throw new TypeError(`${label} contains an unsupported field`);
|
|
56
|
+
const descriptor = descriptors[key];
|
|
57
|
+
if (!descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
58
|
+
throw new TypeError(`${label} must contain only enumerable data properties`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const key of required) {
|
|
62
|
+
if (!Object.hasOwn(descriptors, key)) throw new TypeError(`${label}.${key} is required`);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeUpstream(value) {
|
|
68
|
+
if (typeof value !== "string") throw new TypeError("upstream must be an exact loopback HTTP origin");
|
|
69
|
+
const parsed = new URL(value);
|
|
70
|
+
const port = Number(parsed.port);
|
|
71
|
+
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1"
|
|
72
|
+
|| !parsed.port || !Number.isSafeInteger(port) || port < 1024 || port > 65535
|
|
73
|
+
|| parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
74
|
+
throw new TypeError("upstream must be an exact 127.0.0.1 high-port HTTP origin");
|
|
75
|
+
}
|
|
76
|
+
return parsed.origin;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeContext(value, mutation) {
|
|
80
|
+
const allowed = ["principalId", "browserSession", "idempotencyKey", "signal"];
|
|
81
|
+
const required = mutation ? ["principalId", "browserSession", "idempotencyKey"] : ["principalId", "browserSession"];
|
|
82
|
+
const context = exactDataObject(value, allowed, "AgInTi adapter context", required);
|
|
83
|
+
if (!PRINCIPAL_ID.test(context.principalId)) throw new TypeError("principalId is invalid");
|
|
84
|
+
if (!BROWSER_SESSION.test(context.browserSession)) throw new TypeError("browserSession is invalid");
|
|
85
|
+
if (mutation) {
|
|
86
|
+
if (typeof context.idempotencyKey !== "string" || !/^[A-Za-z0-9._~-]{16,160}$/u.test(context.idempotencyKey)) {
|
|
87
|
+
throw new TypeError("idempotencyKey is invalid");
|
|
88
|
+
}
|
|
89
|
+
} else if (context.idempotencyKey !== undefined) {
|
|
90
|
+
throw new TypeError("read-only AgInTi RPCs may not carry an idempotency key");
|
|
91
|
+
}
|
|
92
|
+
if (context.signal !== undefined && !(context.signal instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal");
|
|
93
|
+
return context;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safeInteger(value, label, { minimum = 0, maximum = Number.MAX_SAFE_INTEGER } = {}) {
|
|
97
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
98
|
+
throw new TypeError(`${label} is invalid`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function validateArtifactContentRequest(value) {
|
|
104
|
+
const input = exactDataObject(
|
|
105
|
+
value,
|
|
106
|
+
["artifactId", "metadataOnly", "range"],
|
|
107
|
+
"artifact content request",
|
|
108
|
+
["artifactId"],
|
|
109
|
+
);
|
|
110
|
+
const artifactId = validateArtifactId(input.artifactId);
|
|
111
|
+
if (input.metadataOnly !== undefined && typeof input.metadataOnly !== "boolean") {
|
|
112
|
+
throw new TypeError("artifact content request.metadataOnly is invalid");
|
|
113
|
+
}
|
|
114
|
+
let range;
|
|
115
|
+
if (input.range !== undefined) {
|
|
116
|
+
const requested = exactDataObject(
|
|
117
|
+
input.range,
|
|
118
|
+
["start", "end"],
|
|
119
|
+
"artifact content request.range",
|
|
120
|
+
["start"],
|
|
121
|
+
);
|
|
122
|
+
const start = safeInteger(requested.start, "artifact content request.range.start");
|
|
123
|
+
const end = requested.end === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: safeInteger(requested.end, "artifact content request.range.end", {
|
|
126
|
+
minimum: start,
|
|
127
|
+
});
|
|
128
|
+
range = Object.freeze({ start, ...(end === undefined ? {} : { end }) });
|
|
129
|
+
}
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
artifactId,
|
|
132
|
+
...(input.metadataOnly === undefined ? {} : { metadataOnly: input.metadataOnly }),
|
|
133
|
+
...(range === undefined ? {} : { range }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function credential(provider) {
|
|
138
|
+
let value;
|
|
139
|
+
try { value = await provider(); }
|
|
140
|
+
catch { fail("AgInTi transport credential is unavailable"); }
|
|
141
|
+
try { validateAgintiTransportCredential(value); }
|
|
142
|
+
catch { fail("AgInTi transport credential is unavailable"); }
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function validateAgintiTransportCredential(value) {
|
|
147
|
+
if (typeof value !== "string" || !TOKEN.test(value)) {
|
|
148
|
+
throw new TypeError("AgInTi transport credential is invalid");
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function mediaType(response) {
|
|
154
|
+
return String(response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function exactDecimalHeader(response, name, { maximum = Number.MAX_SAFE_INTEGER } = {}) {
|
|
158
|
+
const raw = response.headers.get(name);
|
|
159
|
+
if (raw === null || !DECIMAL.test(raw)) {
|
|
160
|
+
fail(`AgInTi artifact ${name} header was invalid`, {
|
|
161
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
162
|
+
statusCode: 502,
|
|
163
|
+
retryable: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const value = Number(raw);
|
|
167
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
|
168
|
+
fail(`AgInTi artifact ${name} header was outside its bound`, {
|
|
169
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
170
|
+
statusCode: 502,
|
|
171
|
+
retryable: false,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function artifactFilename(response) {
|
|
178
|
+
const disposition = response.headers.get("content-disposition");
|
|
179
|
+
const matched = /^attachment; filename="([A-Za-z0-9._-]{1,120})"; filename\*=UTF-8''([^\s;\r\n]{1,3000})$/u.exec(disposition ?? "");
|
|
180
|
+
if (!matched) {
|
|
181
|
+
fail("AgInTi artifact content disposition was invalid", {
|
|
182
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
183
|
+
statusCode: 502,
|
|
184
|
+
retryable: false,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
let filename;
|
|
188
|
+
try { filename = decodeURIComponent(matched[2]); }
|
|
189
|
+
catch {
|
|
190
|
+
fail("AgInTi artifact filename encoding was invalid", {
|
|
191
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
192
|
+
statusCode: 502,
|
|
193
|
+
retryable: false,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const canonical = encodeURIComponent(filename).replace(/['()*]/gu, (character) => (
|
|
197
|
+
`%${character.codePointAt(0).toString(16).toUpperCase()}`
|
|
198
|
+
));
|
|
199
|
+
if (canonical !== matched[2]) {
|
|
200
|
+
fail("AgInTi artifact filename encoding was not canonical", {
|
|
201
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
202
|
+
statusCode: 502,
|
|
203
|
+
retryable: false,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return filename;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseArtifactContentRange(response, requestedRange) {
|
|
210
|
+
const raw = response.headers.get("content-range");
|
|
211
|
+
if (requestedRange === undefined) {
|
|
212
|
+
if (raw !== null) {
|
|
213
|
+
fail("AgInTi returned an unsolicited artifact content range", {
|
|
214
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
215
|
+
statusCode: 502,
|
|
216
|
+
retryable: false,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const match = /^bytes (0|[1-9]\d*)-(0|[1-9]\d*)\/(0|[1-9]\d*)$/u.exec(raw ?? "");
|
|
222
|
+
if (!match) {
|
|
223
|
+
fail("AgInTi artifact content range was invalid", {
|
|
224
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
225
|
+
statusCode: 502,
|
|
226
|
+
retryable: false,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const start = Number(match[1]);
|
|
230
|
+
const end = Number(match[2]);
|
|
231
|
+
const total = Number(match[3]);
|
|
232
|
+
if (![start, end, total].every(Number.isSafeInteger)
|
|
233
|
+
|| start !== requestedRange.start || start > end || end >= total
|
|
234
|
+
|| total < 1 || total > AGINTI_MAX_FILE_ARTIFACT_BYTES
|
|
235
|
+
|| (requestedRange.end !== undefined && end > requestedRange.end)) {
|
|
236
|
+
fail("AgInTi artifact content range disagreed with the request", {
|
|
237
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
238
|
+
statusCode: 502,
|
|
239
|
+
retryable: false,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return Object.freeze({ start, end, total, value: `bytes ${start}-${end}/${total}` });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function requireArtifactResponseHeaders(response, request) {
|
|
246
|
+
if (response.headers.get("cache-control") !== "no-store, private"
|
|
247
|
+
|| response.headers.get("accept-ranges") !== "bytes"
|
|
248
|
+
|| response.headers.get("x-content-type-options") !== "nosniff") {
|
|
249
|
+
fail("AgInTi artifact response security headers were invalid", {
|
|
250
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
251
|
+
statusCode: 502,
|
|
252
|
+
retryable: false,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const range = parseArtifactContentRange(response, request.range);
|
|
256
|
+
const selectedBytes = request.metadataOnly === true
|
|
257
|
+
? exactDecimalHeader(response, "x-artifact-content-length", { maximum: AGINTI_MAX_FILE_ARTIFACT_BYTES })
|
|
258
|
+
: exactDecimalHeader(response, "content-length", { maximum: AGINTI_MAX_FILE_ARTIFACT_BYTES });
|
|
259
|
+
const responseBytes = exactDecimalHeader(response, "content-length", { maximum: AGINTI_MAX_FILE_ARTIFACT_BYTES });
|
|
260
|
+
if ((request.metadataOnly === true && responseBytes !== 0)
|
|
261
|
+
|| (request.metadataOnly !== true && response.headers.get("x-artifact-content-length") !== null)
|
|
262
|
+
|| selectedBytes < 1 || selectedBytes !== (range === null ? selectedBytes : range.end - range.start + 1)) {
|
|
263
|
+
fail("AgInTi artifact response byte metadata was invalid", {
|
|
264
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
265
|
+
statusCode: 502,
|
|
266
|
+
retryable: false,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const totalBytes = range?.total ?? selectedBytes;
|
|
270
|
+
const sha256 = String(response.headers.get("etag") ?? "").replace(/^"|"$/gu, "");
|
|
271
|
+
if (!DIGEST.test(sha256) || response.headers.get("etag") !== `"${sha256}"`) {
|
|
272
|
+
fail("AgInTi artifact ETag was invalid", {
|
|
273
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
274
|
+
statusCode: 502,
|
|
275
|
+
retryable: false,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const mime = String(response.headers.get("content-type") ?? "").toLowerCase();
|
|
279
|
+
const filename = artifactFilename(response);
|
|
280
|
+
try {
|
|
281
|
+
validateFileSpec({ schemaVersion: "1", filename, mime, bytes: totalBytes, sha256 });
|
|
282
|
+
} catch {
|
|
283
|
+
fail("AgInTi artifact metadata was invalid", {
|
|
284
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
285
|
+
statusCode: 502,
|
|
286
|
+
retryable: false,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return Object.freeze({ filename, mime, totalBytes, selectedBytes, sha256, range });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function assertUnencoded(response) {
|
|
293
|
+
if (response.headers.get("content-encoding") !== null) {
|
|
294
|
+
fail("AgInTi returned an encoded response", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function readBoundedText(response, maximum) {
|
|
299
|
+
const advertised = response.headers.get("content-length");
|
|
300
|
+
if (advertised !== null && (!/^\d+$/u.test(advertised) || Number(advertised) >= maximum)) {
|
|
301
|
+
await response.body?.cancel?.().catch(() => {});
|
|
302
|
+
fail("AgInTi response exceeded its public bound", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
303
|
+
}
|
|
304
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
305
|
+
const value = await response.text();
|
|
306
|
+
if (Buffer.byteLength(value, "utf8") >= maximum) {
|
|
307
|
+
fail("AgInTi response exceeded its public bound", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
308
|
+
}
|
|
309
|
+
return value;
|
|
310
|
+
}
|
|
311
|
+
const reader = response.body.getReader();
|
|
312
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
313
|
+
let size = 0;
|
|
314
|
+
let output = "";
|
|
315
|
+
let ended = false;
|
|
316
|
+
try {
|
|
317
|
+
for (;;) {
|
|
318
|
+
const { done, value } = await reader.read();
|
|
319
|
+
if (done) { ended = true; break; }
|
|
320
|
+
if (!(value instanceof Uint8Array)) {
|
|
321
|
+
fail("AgInTi response was not a byte stream", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
322
|
+
}
|
|
323
|
+
size += value.byteLength;
|
|
324
|
+
if (size >= maximum) {
|
|
325
|
+
fail("AgInTi response exceeded its public bound", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
326
|
+
}
|
|
327
|
+
output += decoder.decode(value, { stream: true });
|
|
328
|
+
}
|
|
329
|
+
output += decoder.decode();
|
|
330
|
+
return output;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error instanceof AgintiAdapterError) throw error;
|
|
333
|
+
fail("AgInTi response was not valid UTF-8", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
334
|
+
} finally {
|
|
335
|
+
if (!ended) await reader.cancel().catch(() => {});
|
|
336
|
+
reader.releaseLock?.();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function upstreamError(response) {
|
|
341
|
+
const statusCode = [400, 401, 403, 404, 409, 429, 502, 503, 504].includes(response.status) ? response.status : 503;
|
|
342
|
+
response.body?.cancel?.().catch(() => {});
|
|
343
|
+
return new AgintiAdapterError("AgInTi did not accept the request", {
|
|
344
|
+
code: response.status === 429 ? "AGINTI_RATE_LIMITED" : "AGINTI_UPSTREAM_REJECTED",
|
|
345
|
+
statusCode,
|
|
346
|
+
retryable: response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function responseFailure(error) {
|
|
351
|
+
if (error instanceof AgintiAdapterError) return error;
|
|
352
|
+
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
|
|
353
|
+
return new AgintiAdapterError("AgInTi request was interrupted", {
|
|
354
|
+
code: error.name === "TimeoutError" ? "AGINTI_TIMEOUT" : "AGINTI_ABORTED",
|
|
355
|
+
statusCode: error.name === "TimeoutError" ? 504 : 499,
|
|
356
|
+
retryable: error.name === "TimeoutError",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return new AgintiAdapterError("AgInTi transport is unavailable");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function requestHeaders(token, context, mutation, accept) {
|
|
363
|
+
const headers = new Headers({
|
|
364
|
+
accept,
|
|
365
|
+
authorization: `Bearer ${token}`,
|
|
366
|
+
"content-type": "application/json; charset=utf-8",
|
|
367
|
+
[AGINTI_INTERNAL_HEADERS.principal]: context.principalId,
|
|
368
|
+
[AGINTI_INTERNAL_HEADERS.browserSession]: context.browserSession,
|
|
369
|
+
});
|
|
370
|
+
if (mutation) headers.set(AGINTI_INTERNAL_HEADERS.idempotency, context.idempotencyKey);
|
|
371
|
+
return headers;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function parseSseBlock(block) {
|
|
375
|
+
const fields = Object.create(null);
|
|
376
|
+
for (const line of block.split("\n")) {
|
|
377
|
+
if (!line || line.startsWith(":")) continue;
|
|
378
|
+
const match = /^(id|event|data): ?([^\r\n]*)$/u.exec(line);
|
|
379
|
+
if (!match || Object.hasOwn(fields, match[1])) {
|
|
380
|
+
fail("AgInTi event stream contained an invalid field", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
381
|
+
}
|
|
382
|
+
fields[match[1]] = match[2];
|
|
383
|
+
}
|
|
384
|
+
if (!Object.hasOwn(fields, "id") || !Object.hasOwn(fields, "event") || !Object.hasOwn(fields, "data")) {
|
|
385
|
+
fail("AgInTi event stream block was incomplete", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
386
|
+
}
|
|
387
|
+
let value;
|
|
388
|
+
try { value = JSON.parse(fields.data); }
|
|
389
|
+
catch { fail("AgInTi event stream data was invalid", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false }); }
|
|
390
|
+
return Object.freeze({ id: fields.id, type: fields.event, value });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function eventHash(event) {
|
|
394
|
+
return createHash("sha256").update(canonicalJson({
|
|
395
|
+
schemaVersion: event.schemaVersion,
|
|
396
|
+
id: event.id,
|
|
397
|
+
seq: event.seq,
|
|
398
|
+
type: event.type,
|
|
399
|
+
threadId: event.threadId,
|
|
400
|
+
runId: event.runId,
|
|
401
|
+
createdAt: event.createdAt,
|
|
402
|
+
payload: event.payload,
|
|
403
|
+
previousHash: event.previousHash,
|
|
404
|
+
}), "utf8").digest("hex");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function* sseBlocks(response) {
|
|
408
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
409
|
+
fail("AgInTi event stream body was unavailable", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
410
|
+
}
|
|
411
|
+
const reader = response.body.getReader();
|
|
412
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
413
|
+
let buffer = "";
|
|
414
|
+
let size = 0;
|
|
415
|
+
let pendingCarriageReturn = false;
|
|
416
|
+
let ended = false;
|
|
417
|
+
const normalize = (value, final = false) => {
|
|
418
|
+
let text = pendingCarriageReturn ? `\r${value}` : value;
|
|
419
|
+
pendingCarriageReturn = false;
|
|
420
|
+
if (!final && text.endsWith("\r")) {
|
|
421
|
+
pendingCarriageReturn = true;
|
|
422
|
+
text = text.slice(0, -1);
|
|
423
|
+
}
|
|
424
|
+
return text.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n");
|
|
425
|
+
};
|
|
426
|
+
try {
|
|
427
|
+
for (;;) {
|
|
428
|
+
const { done, value } = await reader.read();
|
|
429
|
+
if (done) { ended = true; break; }
|
|
430
|
+
if (!(value instanceof Uint8Array)) {
|
|
431
|
+
fail("AgInTi event stream was not bytes", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
432
|
+
}
|
|
433
|
+
size += value.byteLength;
|
|
434
|
+
if (size > STREAM_LIMIT) {
|
|
435
|
+
fail("AgInTi event stream exceeded its bound", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
436
|
+
}
|
|
437
|
+
buffer += normalize(decoder.decode(value, { stream: true }));
|
|
438
|
+
let boundary;
|
|
439
|
+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
440
|
+
const block = buffer.slice(0, boundary);
|
|
441
|
+
buffer = buffer.slice(boundary + 2);
|
|
442
|
+
if (Buffer.byteLength(block, "utf8") > SSE_BLOCK_LIMIT) {
|
|
443
|
+
fail("AgInTi event block exceeded its bound", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
444
|
+
}
|
|
445
|
+
if (block && !block.split("\n").every((line) => !line || line.startsWith(":"))) yield block;
|
|
446
|
+
}
|
|
447
|
+
if (Buffer.byteLength(buffer, "utf8") > SSE_BLOCK_LIMIT) {
|
|
448
|
+
fail("AgInTi event block exceeded its bound", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
buffer += normalize(decoder.decode(), true);
|
|
452
|
+
const tail = buffer.trim();
|
|
453
|
+
if (tail) yield tail;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error instanceof AgintiAdapterError) throw error;
|
|
456
|
+
throw responseFailure(error);
|
|
457
|
+
} finally {
|
|
458
|
+
if (!ended) await reader.cancel().catch(() => {});
|
|
459
|
+
reader.releaseLock?.();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function createAgintiAgentAdapter({ upstream, credentialProvider, fetchImpl = globalThis.fetch } = {}) {
|
|
464
|
+
const origin = normalizeUpstream(upstream);
|
|
465
|
+
if (typeof credentialProvider !== "function") throw new TypeError("credentialProvider must be a function");
|
|
466
|
+
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function");
|
|
467
|
+
|
|
468
|
+
async function artifactContent(body, context) {
|
|
469
|
+
const input = validateArtifactContentRequest(body);
|
|
470
|
+
const safeContext = normalizeContext(context, false);
|
|
471
|
+
const token = await credential(credentialProvider);
|
|
472
|
+
let response;
|
|
473
|
+
try {
|
|
474
|
+
response = await fetchImpl(`${origin}${AGINTI_ARTIFACT_CONTENT_PATH}`, {
|
|
475
|
+
method: "POST",
|
|
476
|
+
cache: "no-store",
|
|
477
|
+
redirect: "error",
|
|
478
|
+
headers: requestHeaders(
|
|
479
|
+
token,
|
|
480
|
+
safeContext,
|
|
481
|
+
false,
|
|
482
|
+
"application/pdf, application/x-tex, text/x-tex, application/json",
|
|
483
|
+
),
|
|
484
|
+
body: JSON.stringify(input),
|
|
485
|
+
signal: safeContext.signal,
|
|
486
|
+
});
|
|
487
|
+
} catch (error) { throw responseFailure(error); }
|
|
488
|
+
assertUnencoded(response);
|
|
489
|
+
if ([404, 410, 416].includes(response.status)) {
|
|
490
|
+
if (mediaType(response) !== "application/json"
|
|
491
|
+
|| response.headers.get("cache-control") !== "no-store"
|
|
492
|
+
|| response.headers.get("x-content-type-options") !== "nosniff") {
|
|
493
|
+
await response.body?.cancel?.().catch(() => {});
|
|
494
|
+
fail("AgInTi artifact error response was invalid", {
|
|
495
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
496
|
+
statusCode: 502,
|
|
497
|
+
retryable: false,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
await response.body?.cancel?.().catch(() => {});
|
|
501
|
+
return Object.freeze({ status: response.status });
|
|
502
|
+
}
|
|
503
|
+
const expectedStatus = input.range === undefined ? 200 : 206;
|
|
504
|
+
if (response.status !== expectedStatus) throw upstreamError(response);
|
|
505
|
+
const metadata = requireArtifactResponseHeaders(response, input);
|
|
506
|
+
if (input.metadataOnly === true) {
|
|
507
|
+
await response.body?.cancel?.().catch(() => {});
|
|
508
|
+
return Object.freeze({ status: response.status, ...metadata, body: null });
|
|
509
|
+
}
|
|
510
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
511
|
+
await response.body?.cancel?.().catch(() => {});
|
|
512
|
+
fail("AgInTi artifact byte stream was unavailable", {
|
|
513
|
+
code: "AGINTI_RESPONSE_INVALID",
|
|
514
|
+
statusCode: 502,
|
|
515
|
+
retryable: false,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return Object.freeze({ status: response.status, ...metadata, body: response.body });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function request(pathname, body, context) {
|
|
522
|
+
if (!Object.values(AGINTI_RPC_PATHS).includes(pathname)) throw new TypeError("unknown AgInTi RPC path");
|
|
523
|
+
const mutation = rpcPathIsMutation(pathname);
|
|
524
|
+
const input = validateAgentRequest(pathname, body);
|
|
525
|
+
const safeContext = normalizeContext(context, mutation);
|
|
526
|
+
const token = await credential(credentialProvider);
|
|
527
|
+
const endpoint = `${origin}${pathname}`;
|
|
528
|
+
|
|
529
|
+
if (pathname === AGINTI_RPC_PATHS.runsEvents) {
|
|
530
|
+
let response;
|
|
531
|
+
try {
|
|
532
|
+
response = await fetchImpl(endpoint, {
|
|
533
|
+
method: "POST",
|
|
534
|
+
cache: "no-store",
|
|
535
|
+
redirect: "error",
|
|
536
|
+
headers: requestHeaders(token, safeContext, false, "text/event-stream"),
|
|
537
|
+
body: JSON.stringify(input),
|
|
538
|
+
signal: safeContext.signal,
|
|
539
|
+
});
|
|
540
|
+
} catch (error) { throw responseFailure(error); }
|
|
541
|
+
if (!response.ok) throw upstreamError(response);
|
|
542
|
+
assertUnencoded(response);
|
|
543
|
+
if (mediaType(response) !== "text/event-stream") {
|
|
544
|
+
await response.body?.cancel?.().catch(() => {});
|
|
545
|
+
fail("AgInTi event stream content type was invalid", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
546
|
+
}
|
|
547
|
+
return (async function* events() {
|
|
548
|
+
let sequence = input.afterSeq;
|
|
549
|
+
let previousHash = input.afterHash;
|
|
550
|
+
for await (const block of sseBlocks(response)) {
|
|
551
|
+
const parsed = parseSseBlock(block);
|
|
552
|
+
let event;
|
|
553
|
+
try { event = validateEventEnvelope(parsed.value); }
|
|
554
|
+
catch { fail("AgInTi event envelope was invalid", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false }); }
|
|
555
|
+
if (parsed.id !== event.id || parsed.type !== event.type || event.runId !== input.runId
|
|
556
|
+
|| event.seq !== sequence + 1 || event.previousHash !== previousHash
|
|
557
|
+
|| eventHash(event) !== event.hash) {
|
|
558
|
+
fail("AgInTi event ledger verification failed", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
559
|
+
}
|
|
560
|
+
sequence = event.seq;
|
|
561
|
+
previousHash = event.hash;
|
|
562
|
+
yield event;
|
|
563
|
+
}
|
|
564
|
+
})();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let response;
|
|
568
|
+
try {
|
|
569
|
+
response = await fetchImpl(endpoint, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
cache: "no-store",
|
|
572
|
+
redirect: "error",
|
|
573
|
+
headers: requestHeaders(token, safeContext, mutation, "application/json"),
|
|
574
|
+
body: JSON.stringify(input),
|
|
575
|
+
signal: safeContext.signal,
|
|
576
|
+
});
|
|
577
|
+
} catch (error) { throw responseFailure(error); }
|
|
578
|
+
if (!response.ok) throw upstreamError(response);
|
|
579
|
+
assertUnencoded(response);
|
|
580
|
+
if (mediaType(response) !== "application/json") {
|
|
581
|
+
await response.body?.cancel?.().catch(() => {});
|
|
582
|
+
fail("AgInTi response content type was invalid", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
583
|
+
}
|
|
584
|
+
let decoded;
|
|
585
|
+
try { decoded = JSON.parse(await readBoundedText(response, JSON_LIMIT)); }
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (error instanceof AgintiAdapterError) throw error;
|
|
588
|
+
fail("AgInTi response JSON was invalid", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false });
|
|
589
|
+
}
|
|
590
|
+
try { return validateAgentResponse(pathname, decoded); }
|
|
591
|
+
catch { fail("AgInTi response envelope was invalid", { code: "AGINTI_RESPONSE_INVALID", statusCode: 502, retryable: false }); }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return Object.freeze({
|
|
595
|
+
rpc: request,
|
|
596
|
+
artifactContent,
|
|
597
|
+
async capabilities(context) {
|
|
598
|
+
try { return await request(AGINTI_RPC_PATHS.capabilities, {}, context); }
|
|
599
|
+
catch { return FAIL_CLOSED_AGENT_CAPABILITIES; }
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
}
|