@aexhq/sdk 0.57.5 → 0.58.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/README.md +43 -88
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +8 -10
- package/dist/index.js +3 -7
- package/dist/internal.d.ts +2 -0
- package/dist/internal.js +1 -0
- package/dist/resources.d.ts +12 -55
- package/dist/resources.js +17 -124
- package/dist/session.d.ts +16 -25
- package/dist/session.js +64 -56
- package/dist/tools.d.ts +100 -2
- package/dist/tools.js +424 -1
- package/dist/transport.d.ts +5 -4
- package/dist/transport.js +44 -12
- package/package.json +7 -2
package/dist/tools.js
CHANGED
|
@@ -1 +1,424 @@
|
|
|
1
|
-
|
|
1
|
+
import { inspectEnvironment, isEnvironmentRef, } from "@aexhq/environment";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { jcsSha256 } from "./json.js";
|
|
4
|
+
const EMPTY_INPUT = z.object({});
|
|
5
|
+
const TOOL_NAME = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/u;
|
|
6
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
7
|
+
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
8
|
+
export function tool(inputOrHandler, maybeHandler) {
|
|
9
|
+
if (typeof inputOrHandler === "function") {
|
|
10
|
+
return makeTool({
|
|
11
|
+
...(inputOrHandler.name === "" ? {} : { name: inputOrHandler.name }),
|
|
12
|
+
input: EMPTY_INPUT,
|
|
13
|
+
handler: inputOrHandler,
|
|
14
|
+
requirements: Object.freeze({}),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (typeof maybeHandler !== "function")
|
|
18
|
+
throw new TypeError("tool(schema, handler) requires a function");
|
|
19
|
+
return makeTool({
|
|
20
|
+
...(maybeHandler.name === "" ? {} : { name: maybeHandler.name }),
|
|
21
|
+
input: inputOrHandler,
|
|
22
|
+
handler: maybeHandler,
|
|
23
|
+
requirements: Object.freeze({}),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function makeTool(draft) {
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
kind: "aex.tool",
|
|
29
|
+
...(draft.name === undefined ? {} : { name: draft.name }),
|
|
30
|
+
...(draft.description === undefined ? {} : { description: draft.description }),
|
|
31
|
+
input: draft.input,
|
|
32
|
+
...(draft.output === undefined ? {} : { output: draft.output }),
|
|
33
|
+
requirements: draft.requirements,
|
|
34
|
+
handler: draft.handler,
|
|
35
|
+
...(draft.setupHandler === undefined ? {} : { setupHandler: draft.setupHandler }),
|
|
36
|
+
...(draft.artifact === undefined ? {} : { artifact: draft.artifact }),
|
|
37
|
+
describe(description) {
|
|
38
|
+
if (description.trim() === "" || description.length > 4096) {
|
|
39
|
+
throw new TypeError("Tool description must contain 1 through 4096 characters");
|
|
40
|
+
}
|
|
41
|
+
return makeTool({ ...draft, description });
|
|
42
|
+
},
|
|
43
|
+
named(name) {
|
|
44
|
+
assertToolName(name);
|
|
45
|
+
return makeTool({ ...draft, name });
|
|
46
|
+
},
|
|
47
|
+
returns(output) {
|
|
48
|
+
schemaOf(output, `${draft.name ?? "Tool"} output`);
|
|
49
|
+
return makeTool({ ...draft, output });
|
|
50
|
+
},
|
|
51
|
+
needs(requirements) {
|
|
52
|
+
return makeTool({ ...draft, requirements: normalizeRequirements(requirements) });
|
|
53
|
+
},
|
|
54
|
+
setup(handler) {
|
|
55
|
+
if (typeof handler !== "function")
|
|
56
|
+
throw new TypeError("Tool setup requires a function");
|
|
57
|
+
return makeTool({ ...draft, setupHandler: handler });
|
|
58
|
+
},
|
|
59
|
+
bind(environment) {
|
|
60
|
+
if (!isEnvironmentRef(environment))
|
|
61
|
+
throw new TypeError("Tool binding requires an EnvironmentRef");
|
|
62
|
+
return Object.freeze({ kind: "aex.bound-tool", tool: makeTool(draft), environment });
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function withPreparedArtifact(value, artifact) {
|
|
67
|
+
assertTool(value);
|
|
68
|
+
assertArtifact(artifact);
|
|
69
|
+
return makeTool({
|
|
70
|
+
...(value.name === undefined ? {} : { name: value.name }),
|
|
71
|
+
...(value.description === undefined ? {} : { description: value.description }),
|
|
72
|
+
input: value.input,
|
|
73
|
+
...(value.output === undefined ? {} : { output: value.output }),
|
|
74
|
+
requirements: value.requirements,
|
|
75
|
+
handler: value.handler,
|
|
76
|
+
...(value.setupHandler === undefined ? {} : { setupHandler: value.setupHandler }),
|
|
77
|
+
artifact: Object.freeze({
|
|
78
|
+
...artifact,
|
|
79
|
+
layers: Object.freeze(artifact.layers.map((layer) => Object.freeze({ ...layer }))),
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export async function compileTools(selections, declared, secrets = {}) {
|
|
84
|
+
const environments = normalizeEnvironments(declared);
|
|
85
|
+
const selected = [...(selections ?? [])];
|
|
86
|
+
if (selected.length > 0 && environments.byName.size === 0) {
|
|
87
|
+
throw new TypeError("Tools require at least one declared environment");
|
|
88
|
+
}
|
|
89
|
+
const items = [];
|
|
90
|
+
const bundles = new Map();
|
|
91
|
+
const layers = new Map();
|
|
92
|
+
const registrations = [];
|
|
93
|
+
const names = new Set();
|
|
94
|
+
let callbackClientId;
|
|
95
|
+
for (const selection of selected) {
|
|
96
|
+
const value = selection.kind === "aex.bound-tool" ? selection.tool : selection;
|
|
97
|
+
assertTool(value);
|
|
98
|
+
const contract = await compileContract(value);
|
|
99
|
+
if (names.has(contract.name))
|
|
100
|
+
throw new TypeError(`Tool ${JSON.stringify(contract.name)} was selected twice`);
|
|
101
|
+
names.add(contract.name);
|
|
102
|
+
const explicit = selection.kind === "aex.bound-tool" ? selection.environment : undefined;
|
|
103
|
+
const environment = chooseEnvironment(contract.name, value, explicit, environments);
|
|
104
|
+
const environmentName = environments.byRef.get(environment);
|
|
105
|
+
const environmentProfile = inspectEnvironment(environment).serialized.profile;
|
|
106
|
+
for (const key of value.requirements.env ?? []) {
|
|
107
|
+
if (!(key in secrets))
|
|
108
|
+
throw new TypeError(`Tool ${JSON.stringify(contract.name)} requires missing secret ${JSON.stringify(key)}`);
|
|
109
|
+
}
|
|
110
|
+
const definition = {
|
|
111
|
+
name: contract.name,
|
|
112
|
+
...(contract.description === undefined ? {} : { description: contract.description }),
|
|
113
|
+
input_schema: contract.inputSchema,
|
|
114
|
+
output_schema: contract.outputSchema ?? {},
|
|
115
|
+
contract_digest: contract.contractDigest,
|
|
116
|
+
};
|
|
117
|
+
if (environmentProfile.kind === "callbacks") {
|
|
118
|
+
const descriptor = inspectEnvironment(environment).serialized;
|
|
119
|
+
const id = descriptor.configuration.id;
|
|
120
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
121
|
+
throw new TypeError(`Callback environment ${JSON.stringify(environmentName)} must serialize a non-empty id`);
|
|
122
|
+
}
|
|
123
|
+
if (callbackClientId !== undefined && callbackClientId !== id) {
|
|
124
|
+
throw new TypeError("A session currently supports one callback process id");
|
|
125
|
+
}
|
|
126
|
+
callbackClientId = id;
|
|
127
|
+
const registration = `tool:${contract.contractDigest}`;
|
|
128
|
+
registrations.push(Object.freeze({
|
|
129
|
+
registration,
|
|
130
|
+
name: contract.name,
|
|
131
|
+
contractDigest: contract.contractDigest,
|
|
132
|
+
input: value.input,
|
|
133
|
+
...(value.output === undefined ? {} : { output: value.output }),
|
|
134
|
+
handler: value.handler,
|
|
135
|
+
}));
|
|
136
|
+
items.push({
|
|
137
|
+
definition,
|
|
138
|
+
executor: {
|
|
139
|
+
kind: "environment",
|
|
140
|
+
environment: environmentName,
|
|
141
|
+
callback_registration: registration,
|
|
142
|
+
requirements: wireRequirements(value.requirements),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (value.artifact === undefined) {
|
|
148
|
+
throw new TypeError(`Tool ${JSON.stringify(contract.name)} has no prepared computer artifact`);
|
|
149
|
+
}
|
|
150
|
+
assertArtifact(value.artifact);
|
|
151
|
+
const layerRefs = [];
|
|
152
|
+
for (const layer of value.artifact.layers) {
|
|
153
|
+
const bytes = await readArtifactLayer(layer);
|
|
154
|
+
const existing = layers.get(layer.digest);
|
|
155
|
+
const payload = {
|
|
156
|
+
checksum: layer.digest,
|
|
157
|
+
bytes: layer.bytes,
|
|
158
|
+
media_type: layer.mediaType,
|
|
159
|
+
content: bytes,
|
|
160
|
+
};
|
|
161
|
+
if (existing !== undefined &&
|
|
162
|
+
(existing.bytes !== payload.bytes || existing.media_type !== payload.media_type)) {
|
|
163
|
+
throw new TypeError(`Prepared artifact layer ${layer.digest} conflicts with an earlier layer`);
|
|
164
|
+
}
|
|
165
|
+
layers.set(layer.digest, payload);
|
|
166
|
+
layerRefs.push({
|
|
167
|
+
checksum: layer.digest,
|
|
168
|
+
bytes: layer.bytes,
|
|
169
|
+
media_type: layer.mediaType,
|
|
170
|
+
mount_path: layer.mountPath,
|
|
171
|
+
unpack: layer.unpack,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const manifest = {
|
|
175
|
+
checksum: value.artifact.digest,
|
|
176
|
+
bytes: value.artifact.bytes,
|
|
177
|
+
target: value.artifact.target,
|
|
178
|
+
execute_path: value.artifact.execute,
|
|
179
|
+
...(value.artifact.setup === undefined ? {} : { setup_path: value.artifact.setup }),
|
|
180
|
+
layers: layerRefs,
|
|
181
|
+
};
|
|
182
|
+
const manifestIdentity = {
|
|
183
|
+
profile: "computer/v1",
|
|
184
|
+
target: value.artifact.target,
|
|
185
|
+
execute_path: value.artifact.execute,
|
|
186
|
+
setup_path: value.artifact.setup ?? null,
|
|
187
|
+
layers: layerRefs,
|
|
188
|
+
};
|
|
189
|
+
if (await jcsSha256(manifestIdentity) !== value.artifact.digest) {
|
|
190
|
+
throw new TypeError(`Prepared artifact ${value.artifact.digest} manifest digest changed`);
|
|
191
|
+
}
|
|
192
|
+
const existingManifest = bundles.get(value.artifact.digest);
|
|
193
|
+
if (existingManifest !== undefined && JSON.stringify(existingManifest) !== JSON.stringify(manifest)) {
|
|
194
|
+
throw new TypeError(`Prepared artifact ${value.artifact.digest} conflicts with an earlier manifest`);
|
|
195
|
+
}
|
|
196
|
+
bundles.set(value.artifact.digest, manifest);
|
|
197
|
+
items.push({
|
|
198
|
+
definition,
|
|
199
|
+
executor: {
|
|
200
|
+
kind: "environment",
|
|
201
|
+
environment: environmentName,
|
|
202
|
+
artifact_digest: value.artifact.digest,
|
|
203
|
+
requirements: wireRequirements(value.requirements),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
items: Object.freeze(items),
|
|
209
|
+
bundles: Object.freeze([...bundles.values()]),
|
|
210
|
+
layers: Object.freeze([...layers.values()]),
|
|
211
|
+
clientRegistrations: Object.freeze(registrations),
|
|
212
|
+
environments: environments.serialized,
|
|
213
|
+
environmentNames: environments.byRef,
|
|
214
|
+
...(callbackClientId === undefined ? {} : { callbackClientId }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function normalizeEnvironments(declared) {
|
|
218
|
+
const byName = new Map();
|
|
219
|
+
const byRef = new Map();
|
|
220
|
+
const serialized = Object.create(null);
|
|
221
|
+
for (const [name, environment] of Object.entries(declared ?? {})) {
|
|
222
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u.test(name)) {
|
|
223
|
+
throw new TypeError(`Invalid environment name ${JSON.stringify(name)}`);
|
|
224
|
+
}
|
|
225
|
+
if (!isEnvironmentRef(environment))
|
|
226
|
+
throw new TypeError(`Environment ${JSON.stringify(name)} is not an EnvironmentRef`);
|
|
227
|
+
const previous = byRef.get(environment);
|
|
228
|
+
if (previous !== undefined) {
|
|
229
|
+
throw new TypeError(`The same EnvironmentRef was declared as ${JSON.stringify(previous)} and ${JSON.stringify(name)}`);
|
|
230
|
+
}
|
|
231
|
+
byName.set(name, environment);
|
|
232
|
+
byRef.set(environment, name);
|
|
233
|
+
serialized[name] = inspectEnvironment(environment).serialized;
|
|
234
|
+
}
|
|
235
|
+
return { byName, byRef, serialized: Object.freeze(serialized) };
|
|
236
|
+
}
|
|
237
|
+
function chooseEnvironment(toolName, value, explicit, environments) {
|
|
238
|
+
if (explicit !== undefined) {
|
|
239
|
+
const name = environments.byRef.get(explicit);
|
|
240
|
+
if (name === undefined)
|
|
241
|
+
throw new TypeError(`Tool ${JSON.stringify(toolName)} is bound to an environment absent from environments`);
|
|
242
|
+
const reasons = incompatibilities(value, inspectEnvironment(explicit).serialized.profile);
|
|
243
|
+
if (reasons.length > 0) {
|
|
244
|
+
throw new TypeError(`Tool ${JSON.stringify(toolName)} is bound to ${JSON.stringify(name)}, which ${reasons.join(" and ")}`);
|
|
245
|
+
}
|
|
246
|
+
return explicit;
|
|
247
|
+
}
|
|
248
|
+
const matches = [];
|
|
249
|
+
const failures = [];
|
|
250
|
+
for (const [name, environment] of environments.byName) {
|
|
251
|
+
const reasons = incompatibilities(value, inspectEnvironment(environment).serialized.profile);
|
|
252
|
+
if (reasons.length === 0)
|
|
253
|
+
matches.push(environment);
|
|
254
|
+
else
|
|
255
|
+
failures.push(`- ${JSON.stringify(name)} ${reasons.join(" and ")}`);
|
|
256
|
+
}
|
|
257
|
+
if (matches.length === 1)
|
|
258
|
+
return matches[0];
|
|
259
|
+
if (matches.length === 0) {
|
|
260
|
+
throw new TypeError(`Tool ${JSON.stringify(toolName)} has no compatible environment:\n${failures.join("\n")}`);
|
|
261
|
+
}
|
|
262
|
+
const names = matches.map((environment) => JSON.stringify(environments.byRef.get(environment))).join(", ");
|
|
263
|
+
throw new TypeError(`Tool ${JSON.stringify(toolName)} matches more than one environment: ${names}. Bind it explicitly.`);
|
|
264
|
+
}
|
|
265
|
+
function incompatibilities(value, profile) {
|
|
266
|
+
const reasons = [];
|
|
267
|
+
if (profile.kind === "computer") {
|
|
268
|
+
if (value.artifact === undefined || profile.platform !== value.artifact.target) {
|
|
269
|
+
reasons.push(`cannot launch target ${JSON.stringify(value.artifact?.target ?? "application-callback")}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
if (value.artifact !== undefined)
|
|
274
|
+
reasons.push("does not run prepared artifacts");
|
|
275
|
+
if (value.setupHandler !== undefined)
|
|
276
|
+
reasons.push("does not provide tool setup");
|
|
277
|
+
}
|
|
278
|
+
const needs = value.requirements;
|
|
279
|
+
if (needs.workspace === true && profile.kind !== "computer")
|
|
280
|
+
reasons.push("is missing workspace");
|
|
281
|
+
if (needs.processes === true && profile.kind !== "computer")
|
|
282
|
+
reasons.push("is missing processes");
|
|
283
|
+
if (needs.network !== undefined && needs.network.length > 0 && profile.network !== "allowlist") {
|
|
284
|
+
reasons.push("cannot enforce network allowlists");
|
|
285
|
+
}
|
|
286
|
+
if (needs.recovery !== undefined && needs.recovery !== profile.recovery) {
|
|
287
|
+
reasons.push(`provides ${profile.recovery} recovery instead of ${needs.recovery}`);
|
|
288
|
+
}
|
|
289
|
+
return reasons;
|
|
290
|
+
}
|
|
291
|
+
async function compileContract(value) {
|
|
292
|
+
const name = value.name;
|
|
293
|
+
if (name === undefined)
|
|
294
|
+
throw new TypeError("Tool functions must be named; use .named(name) when build tooling removes the name");
|
|
295
|
+
assertToolName(name);
|
|
296
|
+
const inputSchema = schemaOf(value.input, `${name} input`);
|
|
297
|
+
const outputSchema = value.output === undefined ? undefined : schemaOf(value.output, `${name} output`);
|
|
298
|
+
const canonical = {
|
|
299
|
+
name,
|
|
300
|
+
...(value.description === undefined ? {} : { description: value.description }),
|
|
301
|
+
input_schema: inputSchema,
|
|
302
|
+
...(outputSchema === undefined ? {} : { output_schema: outputSchema }),
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
name,
|
|
306
|
+
...(value.description === undefined ? {} : { description: value.description }),
|
|
307
|
+
inputSchema,
|
|
308
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
309
|
+
contractDigest: await jcsSha256(canonical),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function wireRequirements(requirements) {
|
|
313
|
+
return {
|
|
314
|
+
...(requirements.env === undefined ? {} : { env: [...requirements.env] }),
|
|
315
|
+
...(requirements.workspace === undefined ? {} : { workspace: requirements.workspace }),
|
|
316
|
+
...(requirements.processes === undefined ? {} : { processes: requirements.processes }),
|
|
317
|
+
...(requirements.network === undefined
|
|
318
|
+
? {}
|
|
319
|
+
: { network: requirements.network.map(({ host, port }) => ({ host, ports: [port], protocol: "tls" })) }),
|
|
320
|
+
...(requirements.streaming === undefined ? {} : { streaming: requirements.streaming }),
|
|
321
|
+
...(requirements.recovery === undefined
|
|
322
|
+
? {}
|
|
323
|
+
: { recovery: requirements.recovery === "replay-safe" ? "replay_safe" : requirements.recovery }),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function normalizeRequirements(requirements) {
|
|
327
|
+
const env = [...(requirements.env ?? [])];
|
|
328
|
+
if (new Set(env).size !== env.length)
|
|
329
|
+
throw new TypeError("Tool environment requirements contain duplicates");
|
|
330
|
+
for (const key of env)
|
|
331
|
+
if (!ENV_NAME.test(key))
|
|
332
|
+
throw new TypeError(`Invalid required environment name ${JSON.stringify(key)}`);
|
|
333
|
+
const network = [...(requirements.network ?? [])];
|
|
334
|
+
if (network.length > 32)
|
|
335
|
+
throw new TypeError("Tool network requirements exceed 32 destinations");
|
|
336
|
+
for (const destination of network) {
|
|
337
|
+
if (destination.host.trim() === "" || destination.host.length > 253)
|
|
338
|
+
throw new TypeError("Invalid tool network host");
|
|
339
|
+
if (!Number.isInteger(destination.port) || destination.port < 1 || destination.port > 65535) {
|
|
340
|
+
throw new TypeError("Invalid tool network port");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return Object.freeze({
|
|
344
|
+
...(env.length === 0 ? {} : { env: Object.freeze(env) }),
|
|
345
|
+
...(requirements.workspace === undefined ? {} : { workspace: requirements.workspace }),
|
|
346
|
+
...(requirements.processes === undefined ? {} : { processes: requirements.processes }),
|
|
347
|
+
...(network.length === 0 ? {} : { network: Object.freeze(network.map((item) => Object.freeze({ ...item }))) }),
|
|
348
|
+
...(requirements.streaming === undefined ? {} : { streaming: requirements.streaming }),
|
|
349
|
+
...(requirements.recovery === undefined ? {} : { recovery: requirements.recovery }),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
function schemaOf(schema, label) {
|
|
353
|
+
try {
|
|
354
|
+
const value = z.toJSONSchema(schema, { target: "draft-2020-12", unrepresentable: "throw" });
|
|
355
|
+
if (Object.keys(value).length === 0)
|
|
356
|
+
throw new TypeError(`${label} schema is empty`);
|
|
357
|
+
return value;
|
|
358
|
+
}
|
|
359
|
+
catch (cause) {
|
|
360
|
+
throw new TypeError(`${label} cannot be represented as JSON Schema`, { cause });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function assertTool(value) {
|
|
364
|
+
if (value?.kind !== "aex.tool")
|
|
365
|
+
throw new TypeError("Tool selection is invalid");
|
|
366
|
+
}
|
|
367
|
+
function assertToolName(name) {
|
|
368
|
+
if (!TOOL_NAME.test(name))
|
|
369
|
+
throw new TypeError(`Invalid Tool name ${JSON.stringify(name)}`);
|
|
370
|
+
}
|
|
371
|
+
function assertArtifact(artifact) {
|
|
372
|
+
if (!SHA256.test(artifact.digest))
|
|
373
|
+
throw new TypeError("Prepared artifact digest must be lower-case SHA-256 hex");
|
|
374
|
+
if (!Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1)
|
|
375
|
+
throw new TypeError("Prepared artifact bytes are invalid");
|
|
376
|
+
if (!artifact.execute.startsWith("/"))
|
|
377
|
+
throw new TypeError("Prepared artifact execute entrypoint must be absolute");
|
|
378
|
+
if (artifact.setup !== undefined && !artifact.setup.startsWith("/")) {
|
|
379
|
+
throw new TypeError("Prepared artifact setup entrypoint must be absolute");
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(artifact.layers) || artifact.layers.length === 0) {
|
|
382
|
+
throw new TypeError("Prepared artifact must contain at least one immutable layer");
|
|
383
|
+
}
|
|
384
|
+
let bytes = 0;
|
|
385
|
+
for (const layer of artifact.layers) {
|
|
386
|
+
if (!SHA256.test(layer.digest))
|
|
387
|
+
throw new TypeError("Prepared artifact layer digest must be lower-case SHA-256 hex");
|
|
388
|
+
if (!Number.isSafeInteger(layer.bytes) || layer.bytes < 1)
|
|
389
|
+
throw new TypeError("Prepared artifact layer bytes are invalid");
|
|
390
|
+
if (!(layer.source instanceof URL))
|
|
391
|
+
throw new TypeError("Prepared artifact layer source must be a URL");
|
|
392
|
+
if (!layer.mountPath.startsWith("/"))
|
|
393
|
+
throw new TypeError("Prepared artifact layer mount path must be absolute");
|
|
394
|
+
bytes += layer.bytes;
|
|
395
|
+
}
|
|
396
|
+
if (bytes !== artifact.bytes)
|
|
397
|
+
throw new TypeError("Prepared artifact bytes do not equal its layers");
|
|
398
|
+
}
|
|
399
|
+
async function readArtifactLayer(layer) {
|
|
400
|
+
let bytes;
|
|
401
|
+
if (layer.source.protocol === "file:") {
|
|
402
|
+
const { readFile } = await import("node:fs/promises");
|
|
403
|
+
bytes = await readFile(layer.source);
|
|
404
|
+
}
|
|
405
|
+
else if (layer.source.protocol === "https:" || layer.source.protocol === "data:") {
|
|
406
|
+
const response = await fetch(layer.source);
|
|
407
|
+
if (!response.ok)
|
|
408
|
+
throw new TypeError(`Could not read artifact layer ${layer.digest}: HTTP ${response.status}`);
|
|
409
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
throw new TypeError(`Unsupported artifact layer source protocol ${layer.source.protocol}`);
|
|
413
|
+
}
|
|
414
|
+
if (bytes.byteLength !== layer.bytes)
|
|
415
|
+
throw new TypeError(`Artifact layer ${layer.digest} byte length changed`);
|
|
416
|
+
const actual = await sha256(bytes);
|
|
417
|
+
if (actual !== layer.digest)
|
|
418
|
+
throw new TypeError(`Artifact layer ${layer.digest} content digest changed`);
|
|
419
|
+
return bytes;
|
|
420
|
+
}
|
|
421
|
+
async function sha256(bytes) {
|
|
422
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
|
|
423
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
424
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Event } from "@aexhq/
|
|
2
|
-
import type { JsonRequestOptions, TransferTicket } from "@aexhq/
|
|
1
|
+
import type { Event } from "@aexhq/session-protocol/session";
|
|
2
|
+
import type { JsonRequestOptions, TransferTicket } from "@aexhq/session-protocol";
|
|
3
3
|
export type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
4
4
|
export interface EventOptions {
|
|
5
5
|
after?: number;
|
|
@@ -10,17 +10,18 @@ export declare class Transport {
|
|
|
10
10
|
#private;
|
|
11
11
|
readonly baseUrl: string;
|
|
12
12
|
constructor(apiKey: string, baseUrl: string, fetchImplementation: Fetch);
|
|
13
|
-
|
|
13
|
+
customerEnvironmentGrant(clientId: string, signal?: AbortSignal): Promise<{
|
|
14
14
|
url: string;
|
|
15
15
|
protocol: string;
|
|
16
16
|
expiresAt: string;
|
|
17
17
|
observationUrl: string;
|
|
18
18
|
observationToken: string;
|
|
19
19
|
}>;
|
|
20
|
-
|
|
20
|
+
customerEnvironmentObserve(url: string, token: string, observation: unknown): Promise<void>;
|
|
21
21
|
downloadTransfer(ticket: TransferTicket, signal?: AbortSignal, expectedBytes?: number): Promise<Uint8Array>;
|
|
22
22
|
downloadTransferStream(ticket: TransferTicket, signal?: AbortSignal, expectedBytes?: number): Promise<ReadableStream<Uint8Array>>;
|
|
23
23
|
uploadTransfer(ticket: TransferTicket, content: Uint8Array | (() => ReadableStream<Uint8Array>), bytes: number, signal?: AbortSignal): Promise<void>;
|
|
24
|
+
ensureArtifactLayer(digest: string, mediaType: string, content: Uint8Array, signal?: AbortSignal): Promise<void>;
|
|
24
25
|
json<T>(method: "GET" | "POST" | "DELETE", path: string, options?: JsonRequestOptions): Promise<T>;
|
|
25
26
|
/** Accept one durable deletion job; strict callers poll its short status resource client-side. */
|
|
26
27
|
deleteSession(sessionId: string, waitForCompletion: boolean, signal?: AbortSignal): Promise<void>;
|
package/dist/transport.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { MAX_CREATE_SESSION_REQUEST_BYTES, MAX_CUSTOMER_OBSERVATION_BYTES, MAX_MESSAGE_REQUEST_BYTES, MAX_PUBLIC_EVENT_BYTES, } from "@aexhq/
|
|
1
|
+
import { MAX_CREATE_SESSION_REQUEST_BYTES, MAX_CUSTOMER_OBSERVATION_BYTES, MAX_MESSAGE_REQUEST_BYTES, MAX_PUBLIC_EVENT_BYTES, } from "@aexhq/session-protocol";
|
|
2
2
|
import { AbortError, AexError, SessionError, abortError, errorFromApi } from "./errors.js";
|
|
3
3
|
const MAX_ORDINARY_JSON_BYTES = 2 * 1024 * 1024;
|
|
4
4
|
const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
|
|
5
|
-
const
|
|
5
|
+
const CUSTOMER_ENVIRONMENT_OBSERVATION_TIMEOUT_MS = 15_000;
|
|
6
6
|
export class Transport {
|
|
7
7
|
baseUrl;
|
|
8
8
|
#apiKey;
|
|
@@ -15,9 +15,9 @@ export class Transport {
|
|
|
15
15
|
this.baseUrl = baseUrl.slice(0, end);
|
|
16
16
|
this.#fetch = fetchImplementation;
|
|
17
17
|
}
|
|
18
|
-
async
|
|
19
|
-
const grant = await this.json("POST", "/v1/customer-
|
|
20
|
-
const observationUrl =
|
|
18
|
+
async customerEnvironmentGrant(clientId, signal) {
|
|
19
|
+
const grant = await this.json("POST", "/v1/customer-environment/grants", { body: { client_id: clientId }, signal });
|
|
20
|
+
const observationUrl = validateCustomerEnvironmentGrant(this.baseUrl, grant);
|
|
21
21
|
return {
|
|
22
22
|
url: grant.url,
|
|
23
23
|
protocol: grant.protocol,
|
|
@@ -26,8 +26,8 @@ export class Transport {
|
|
|
26
26
|
observationToken: grant.observation_token,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
async
|
|
30
|
-
const body = encodeJsonOnce(observation, MAX_CUSTOMER_OBSERVATION_BYTES, "Customer
|
|
29
|
+
async customerEnvironmentObserve(url, token, observation) {
|
|
30
|
+
const body = encodeJsonOnce(observation, MAX_CUSTOMER_OBSERVATION_BYTES, "Customer Environment observation");
|
|
31
31
|
const response = await this.#fetch(url, {
|
|
32
32
|
method: "POST",
|
|
33
33
|
redirect: "error",
|
|
@@ -37,13 +37,13 @@ export class Transport {
|
|
|
37
37
|
"Content-Type": "application/json",
|
|
38
38
|
},
|
|
39
39
|
body,
|
|
40
|
-
signal: AbortSignal.timeout(
|
|
40
|
+
signal: AbortSignal.timeout(CUSTOMER_ENVIRONMENT_OBSERVATION_TIMEOUT_MS),
|
|
41
41
|
});
|
|
42
42
|
if (response.ok)
|
|
43
43
|
return;
|
|
44
44
|
const preview = await readResponseText(response, 4096)
|
|
45
45
|
.catch(() => "<response too large or unreadable>");
|
|
46
|
-
throw new SessionError(`Customer
|
|
46
|
+
throw new SessionError(`Customer Environment observation ingress returned HTTP ${response.status}: ${preview}`, { status: response.status, requestId: response.headers.get("x-request-id") ?? undefined });
|
|
47
47
|
}
|
|
48
48
|
async downloadTransfer(ticket, signal, expectedBytes) {
|
|
49
49
|
const stream = await this.downloadTransferStream(ticket, signal, expectedBytes);
|
|
@@ -127,6 +127,38 @@ export class Transport {
|
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
async ensureArtifactLayer(digest, mediaType, content, signal) {
|
|
131
|
+
const path = `/v1/artifacts/${encodeURIComponent(digest)}`;
|
|
132
|
+
const authorization = `Bearer ${this.#apiKey}`;
|
|
133
|
+
const existing = await this.#fetch(`${this.baseUrl}${path}`, {
|
|
134
|
+
method: "HEAD",
|
|
135
|
+
redirect: "error",
|
|
136
|
+
headers: { Authorization: authorization },
|
|
137
|
+
...(signal === undefined ? {} : { signal }),
|
|
138
|
+
});
|
|
139
|
+
if (existing.ok) {
|
|
140
|
+
const bytes = Number(existing.headers.get("content-length"));
|
|
141
|
+
const storedMediaType = existing.headers.get("content-type");
|
|
142
|
+
if (bytes !== content.byteLength || storedMediaType !== mediaType) {
|
|
143
|
+
throw new SessionError(`Uploaded Tool artifact ${digest} has conflicting metadata`);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (existing.status !== 404)
|
|
148
|
+
throw await this.responseError(existing);
|
|
149
|
+
const uploaded = await this.#fetch(`${this.baseUrl}${path}`, {
|
|
150
|
+
method: "PUT",
|
|
151
|
+
redirect: "error",
|
|
152
|
+
headers: {
|
|
153
|
+
Authorization: authorization,
|
|
154
|
+
"Content-Type": mediaType,
|
|
155
|
+
},
|
|
156
|
+
body: arrayBufferBody(content),
|
|
157
|
+
...(signal === undefined ? {} : { signal }),
|
|
158
|
+
});
|
|
159
|
+
if (!uploaded.ok)
|
|
160
|
+
throw await this.responseError(uploaded);
|
|
161
|
+
}
|
|
130
162
|
async json(method, path, options = {}) {
|
|
131
163
|
const attempts = options.retry === true ? 2 : 1;
|
|
132
164
|
// Serialize once before the first await. A caller mutating its source object after a lost
|
|
@@ -419,7 +451,7 @@ function assertTransferTicket(ticket, method) {
|
|
|
419
451
|
throw new SessionError("Aex file transfers require HTTPS (or loopback HTTP for development)");
|
|
420
452
|
}
|
|
421
453
|
}
|
|
422
|
-
function
|
|
454
|
+
function validateCustomerEnvironmentGrant(baseUrl, grant) {
|
|
423
455
|
if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(grant.grant_id)) {
|
|
424
456
|
throw new SessionError("Aex returned an invalid customer Hand grant id");
|
|
425
457
|
}
|
|
@@ -435,7 +467,7 @@ function validateCustomerHandGrant(baseUrl, grant) {
|
|
|
435
467
|
try {
|
|
436
468
|
socket = new URL(grant.url);
|
|
437
469
|
observation = new URL(grant.observation_url);
|
|
438
|
-
expectedObservation = new URL(`${baseUrl}/v1/customer-
|
|
470
|
+
expectedObservation = new URL(`${baseUrl}/v1/customer-environment/observations/${encodeURIComponent(grant.grant_id)}`);
|
|
439
471
|
}
|
|
440
472
|
catch (cause) {
|
|
441
473
|
throw new SessionError("Aex returned an invalid customer Hand URL", { cause });
|
|
@@ -447,7 +479,7 @@ function validateCustomerHandGrant(baseUrl, grant) {
|
|
|
447
479
|
throw new SessionError("Aex customer Hand sockets require credential-free WSS (or loopback WS for development)");
|
|
448
480
|
}
|
|
449
481
|
if (observation.href !== expectedObservation.href) {
|
|
450
|
-
throw new SessionError("Aex returned an unsafe customer
|
|
482
|
+
throw new SessionError("Aex returned an unsafe customer Environment observation URL");
|
|
451
483
|
}
|
|
452
484
|
if (observation.href.includes(grant.observation_token)) {
|
|
453
485
|
throw new SessionError("Aex returned an observation URL containing its bearer token");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aexhq/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "TypeScript SDK for Aex sessions",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://aex.dev",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
24
24
|
"import": "./dist/index.js"
|
|
25
25
|
},
|
|
26
|
+
"./internal": {
|
|
27
|
+
"types": "./dist/internal.d.ts",
|
|
28
|
+
"import": "./dist/internal.js"
|
|
29
|
+
},
|
|
26
30
|
"./package.json": "./package.json"
|
|
27
31
|
},
|
|
28
32
|
"files": [
|
|
@@ -34,7 +38,8 @@
|
|
|
34
38
|
"test": "tsc -p tsconfig.json && node --test"
|
|
35
39
|
},
|
|
36
40
|
"dependencies": {
|
|
37
|
-
"@aexhq/
|
|
41
|
+
"@aexhq/session-protocol": "0.3.1",
|
|
42
|
+
"@aexhq/environment": "0.1.0"
|
|
38
43
|
},
|
|
39
44
|
"peerDependencies": {
|
|
40
45
|
"zod": "4.4.3"
|